Editor UI window that displays remote-only or changed fields for a resource, letting the user review additive remote semantics and optionally pull them into local files. It renders lists of added and changed fields, shows explanations, and provides Cancel, optional View Diff, and Pull actions.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Editor;
/// <summary>
/// Shows additive remote-only fields after a push or remote check so the user can
/// review and pull those semantics into their local files.
/// </summary>
public class MergeViewWindow : PaintedWindow
{
public struct FieldDiff
{
public string Name;
public string LocalValue;
public string RemoteValue;
public string Reason;
public bool IsAdded;
}
private readonly string _resourceName;
private readonly string _resourceType;
private readonly List<FieldDiff> _addedFields;
private readonly List<FieldDiff> _changedFields;
private readonly Action _onMerge;
// Optional inspect-only callback. When set, the window renders a
// "View Diff" button so reviewers can drill into the full local-vs-remote
// content before deciding whether to pull. Useful when the additive-fields
// summary keeps reappearing after pull/push/sync and the reviewer wants
// to confirm what the classifier is actually seeing.
private readonly Action _onViewDiff;
private Vector2 _mousePos;
private float _scroll;
private Rect _cancelRect;
private Rect _viewDiffRect;
private Rect _mergeRect;
private const float LineH = 16f;
private const float FieldBlockH = 56f;
private static readonly Dictionary<string, string> Explanations = new()
{
["rateLimits"] = "Rate limit configuration. Controls how many saves per time period are allowed.",
["rateLimitAction"] = "Action when rate limit is exceeded. 'reject' returns an error, 'clamp' caps values.",
["webhookOnRateLimit"] = "Discord webhook notification when a rate limit is triggered.",
["version"] = "Collection API version. Set to v3 for all new collections.",
["accessMode"] = "Controls whether the collection is publicly readable or endpoint-only.",
["maxRecords"] = "Maximum number of records per player in this collection.",
["allowRecordDelete"] = "Whether players can delete their own records.",
["requireSaveVersion"] = "Version tracking for conflict detection on saves.",
["collectionType"] = "Whether data is stored per-player or globally.",
["input"] = "Input validation schema. Defines what parameters the endpoint accepts.",
["description"] = "Human-readable description of this resource.",
["notes"] = "Editor notes saved with this resource.",
["builtIn"] = "System flag added by the remote project metadata.",
["enabled"] = "Whether this resource is active.",
};
public MergeViewWindow( string resourceName, string resourceType,
List<FieldDiff> addedFields, List<FieldDiff> changedFields,
Action onMerge, Action onViewDiff = null )
{
_resourceName = resourceName;
_resourceType = resourceType;
_addedFields = addedFields;
_changedFields = changedFields;
_onMerge = onMerge;
_onViewDiff = onViewDiff;
Title = $"Pull Remote Semantics - {resourceName}";
var fieldCount = addedFields.Count + changedFields.Count;
Size = new Vector2( 540, Math.Min( 640, 260 + fieldCount * FieldBlockH ) );
MinimumSize = new Vector2( 420, 280 );
}
/// <summary>
/// Compare local and remote JSON to identify additive remote fields versus real content changes.
/// Returns (addedFields, changedFields, isRemoteAdditiveOnly).
/// </summary>
public static (List<FieldDiff> Added, List<FieldDiff> Changed, bool IsRemoteAdditiveOnly) AnalyzeDifferences(
string localJson, string remoteJson )
{
var analysis = JsonDiffUtilities.Analyze( localJson, remoteJson );
return (
analysis.Added.Select( ToFieldDiff ).ToList(),
analysis.Changed.Select( ToFieldDiff ).ToList(),
analysis.IsRemoteAdditiveOnly
);
}
private float ContentHeight => 180 + ( _addedFields.Count + _changedFields.Count ) * FieldBlockH + 80;
private float MaxScroll => Math.Max( 0, ContentHeight - Height + 40 );
protected override void OnContentPaint()
{
base.OnContentPaint();
var pad = 20f;
var w = Width - pad * 2;
var y = 20f - _scroll;
Paint.SetDefaultFont( size: 13, weight: 700 );
Paint.SetPen( Color.White );
Paint.DrawText( new Rect( pad, y, w, 22 ), $"Pull Remote Semantics - {_resourceName}", TextFlag.LeftCenter );
y += 30;
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
DrawWrappedText( pad, ref y, w,
$"The remote version of this {_resourceType} contains additive fields only. " +
"Your local content still matches what is stored remotely. Review the remote-only semantics below and pull them into your local files if you want to keep those additions locally." );
y += 12;
Paint.SetDefaultFont( size: 9, weight: 600 );
var sx = pad;
if ( _addedFields.Count > 0 )
{
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 100, 14 ), $"+{_addedFields.Count} added", TextFlag.LeftCenter );
sx += 80;
}
if ( _changedFields.Count > 0 )
{
Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 110, 14 ), $"~{_changedFields.Count} changed", TextFlag.LeftCenter );
}
y += 22;
Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
y += 8;
if ( _addedFields.Count > 0 )
{
Paint.SetDefaultFont( size: 9, weight: 700 );
Paint.SetPen( Color.Green.WithAlpha( 0.7f ) );
Paint.DrawText( new Rect( pad, y, w, 16 ), "ADDED ON REMOTE", TextFlag.LeftCenter );
y += 22;
foreach ( var field in _addedFields )
DrawFieldBlock( pad, w, ref y, field, Color.Green );
}
if ( _changedFields.Count > 0 )
{
Paint.SetDefaultFont( size: 9, weight: 700 );
Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
Paint.DrawText( new Rect( pad, y, w, 16 ), "DIFFERENT ON REMOTE", TextFlag.LeftCenter );
y += 22;
foreach ( var field in _changedFields )
DrawFieldBlock( pad, w, ref y, field, Color.Yellow );
}
y += 16;
Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
y += 12;
var btnH = 34f;
var gapW = 12f;
var showViewDiff = _onViewDiff != null;
// Layout: Cancel | (View Diff)? | Pull Remote Semantics
// View Diff is optional so the inspect-only callback stays opt-in for
// callers that don't have YAML strings to hand over.
var cancelW = 80f;
var viewDiffW = showViewDiff ? 100f : 0f;
var viewDiffSlot = showViewDiff ? viewDiffW + gapW : 0f;
var mergeW = w - cancelW - viewDiffSlot - gapW;
_cancelRect = new Rect( pad, y, cancelW, btnH );
var cancelHovered = _cancelRect.IsInside( _mousePos );
Paint.SetBrush( Color.White.WithAlpha( cancelHovered ? 0.1f : 0.04f ) );
Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.3f : 0.15f ) );
Paint.DrawRect( _cancelRect, 4 );
Paint.SetDefaultFont( size: 11, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.9f : 0.6f ) );
Paint.DrawText( _cancelRect, "Cancel", TextFlag.Center );
var mergeX = pad + cancelW + gapW;
if ( showViewDiff )
{
_viewDiffRect = new Rect( mergeX, y, viewDiffW, btnH );
var diffHovered = _viewDiffRect.IsInside( _mousePos );
Paint.SetBrush( Color.Cyan.WithAlpha( diffHovered ? 0.18f : 0.06f ) );
Paint.SetPen( Color.Cyan.WithAlpha( diffHovered ? 0.55f : 0.25f ) );
Paint.DrawRect( _viewDiffRect, 4 );
Paint.SetDefaultFont( size: 11, weight: 600 );
Paint.SetPen( Color.Cyan.WithAlpha( diffHovered ? 1f : 0.75f ) );
Paint.DrawText( _viewDiffRect, "View Diff", TextFlag.Center );
mergeX += viewDiffW + gapW;
}
_mergeRect = new Rect( mergeX, y, mergeW, btnH );
var mergeHovered = _mergeRect.IsInside( _mousePos );
Paint.SetBrush( Color.Green.WithAlpha( mergeHovered ? 0.25f : 0.12f ) );
Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 0.6f : 0.3f ) );
Paint.DrawRect( _mergeRect, 4 );
Paint.SetDefaultFont( size: 11, weight: 700 );
Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 1f : 0.85f ) );
Paint.DrawText( _mergeRect, "Pull Remote Semantics", TextFlag.Center );
}
private void DrawFieldBlock( float pad, float w, ref float y, FieldDiff field, Color accentColor )
{
Paint.SetBrush( accentColor.WithAlpha( 0.04f ) );
Paint.SetPen( accentColor.WithAlpha( 0.12f ) );
Paint.DrawRect( new Rect( pad, y, w, FieldBlockH - 6 ), 4 );
Paint.SetDefaultFont( size: 10, weight: 600 );
Paint.SetPen( accentColor.WithAlpha( 0.9f ) );
var prefix = field.IsAdded ? "+" : "~";
Paint.DrawText( new Rect( pad + 10, y + 4, w - 20, 16 ), $"{prefix} {field.Name}", TextFlag.LeftCenter );
Paint.SetDefaultFont( size: 9 );
if ( field.IsAdded )
{
Paint.SetPen( Color.White.WithAlpha( 0.7f ) );
Paint.DrawText( new Rect( pad + 10, y + 20, w - 20, 14 ), field.RemoteValue, TextFlag.LeftCenter );
}
else
{
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
var valueText = $"{field.LocalValue ?? "null"} -> {field.RemoteValue}";
Paint.DrawText( new Rect( pad + 10, y + 20, w - 20, 14 ), valueText, TextFlag.LeftCenter );
}
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
Paint.DrawText( new Rect( pad + 10, y + 34, w - 20, 12 ), field.Reason, TextFlag.LeftCenter );
y += FieldBlockH;
}
private void DrawWrappedText( float x, ref float y, float maxW, string text )
{
var words = text.Split( ' ' );
var line = "";
foreach ( var word in words )
{
var test = string.IsNullOrEmpty( line ) ? word : $"{line} {word}";
if ( test.Length * 6.2f > maxW && !string.IsNullOrEmpty( line ) )
{
Paint.DrawText( new Rect( x, y, maxW, 16 ), line, TextFlag.LeftCenter );
y += 17;
line = word;
}
else
{
line = test;
}
}
if ( !string.IsNullOrEmpty( line ) )
{
Paint.DrawText( new Rect( x, y, maxW, 16 ), line, TextFlag.LeftCenter );
y += 17;
}
}
protected override void OnContentMousePress( MouseEvent e )
{
base.OnContentMousePress(e);
if ( _cancelRect.IsInside( e.LocalPosition ) )
{
Close();
}
else if ( _onViewDiff != null && _viewDiffRect.IsInside( e.LocalPosition ) )
{
// Inspect-only — keep the merge window open so the reviewer can
// flip back and forth between the field summary and the full diff.
_onViewDiff();
}
else if ( _mergeRect.IsInside( e.LocalPosition ) )
{
Close();
_onMerge?.Invoke();
}
}
protected override void OnContentMouseMove( MouseEvent e )
{
base.OnContentMouseMove(e);
_mousePos = e.LocalPosition;
Update();
}
protected override void OnContentMouseWheel( WheelEvent e )
{
var direction = e.Delta > 0 ? -1 : 1;
_scroll = Math.Clamp( _scroll + direction * LineH * 3, 0, MaxScroll );
Update();
e.Accept();
}
protected override void OnContentKeyPress( KeyEvent e )
{
switch ( e.Key )
{
case KeyCode.Escape: Close(); break;
case KeyCode.Up: _scroll = Math.Max( 0, _scroll - LineH ); Update(); break;
case KeyCode.Down: _scroll = Math.Min( MaxScroll, _scroll + LineH ); Update(); break;
case KeyCode.PageUp: _scroll = Math.Max( 0, _scroll - LineH * 10 ); Update(); break;
case KeyCode.PageDown: _scroll = Math.Min( MaxScroll, _scroll + LineH * 10 ); Update(); break;
default: base.OnContentKeyPress(e); break;
}
}
private static FieldDiff ToFieldDiff( JsonDiffUtilities.FieldDifference diff )
{
return new FieldDiff
{
Name = diff.Path,
LocalValue = diff.LocalValue,
RemoteValue = diff.RemoteValue,
Reason = GetReason( diff.Path, diff.IsAdded ),
IsAdded = diff.IsAdded
};
}
private static string GetReason( string path, bool isAdded )
{
var key = path?.Split( '.' ).LastOrDefault() ?? "";
if ( Explanations.TryGetValue( key, out var explanation ) )
return explanation;
return isAdded
? "Present on remote but missing locally."
: "Value differs between local and remote.";
}
}