An editor UI window that displays a side-by-side diff between two text revisions. It computes a line-based LCS diff, optionally aligns added/deleted lines, highlights differing characters, builds HTML tables for each side, and shows them in synchronized scroll areas with toolbar controls for sync, line alignment and word wrap.
using Editor;
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Checkbox = Editor.Checkbox;
using Label = Editor.Label;
public partial class LocalControlWidget
{
/// <summary>
/// A visual diff comparison window showing changes between file revisions.
/// Features include synchronized scrolling, line-level character highlighting, and optional word wrapping.
/// </summary>
public class DiffWindow : Window
{
private string _oldContent;
private string _newContent;
private string _fileName;
private Widget _contentArea;
private ScrollArea _leftScroll;
private ScrollArea _rightScroll;
private Checkbox _syncCb;
private bool _syncScrolling = true;
private bool _lineLeveling = true;
private bool _wordWrap = false;
private float _lastVerticalScroll = -1;
public DiffWindow( string fileName, string oldContent, string newContent )
{
_fileName = fileName;
_oldContent = oldContent ?? "[No previous version]";
_newContent = newContent ?? "[No current version]";
string escapedFileName = System.Security.SecurityElement.Escape( fileName );
WindowTitle = $"Diff: {escapedFileName}";
Size = new Vector2( 1300, 850 );
var root = new Widget( null );
root.Layout = Layout.Column();
root.Layout.Margin = 10;
root.Layout.Spacing = 0;
BuildToolbar( root );
_contentArea = new Widget( root );
_contentArea.Layout = Layout.Column();
root.Layout.Add( _contentArea, 1 );
RefreshDiffView();
Canvas = root;
}
private void BuildToolbar( Widget parent )
{
var toolbar = parent.Layout.AddRow();
toolbar.Spacing = 20;
toolbar.Margin = new Margin( 0, 0, 0, 12 );
var syncGroup = toolbar.AddRow();
syncGroup.Spacing = 5;
_syncCb = new Checkbox( parent );
_syncCb.Value = _syncScrolling;
_syncCb.Toggled += () => { _syncScrolling = _syncCb.Value; };
syncGroup.Add( _syncCb );
syncGroup.Add( new Label( "Sync Scroll", parent ) );
var levelGroup = toolbar.AddRow();
levelGroup.Spacing = 5;
var levelCb = new Checkbox( parent );
levelCb.Value = _lineLeveling;
levelCb.Toggled += () => { _lineLeveling = levelCb.Value; RefreshDiffView(); };
levelGroup.Add( levelCb );
levelGroup.Add( new Label( "Align Lines", parent ) );
var wrapGroup = toolbar.AddRow();
wrapGroup.Spacing = 5;
var wrapCb = new Checkbox( parent );
wrapCb.Value = _wordWrap;
wrapCb.Toggled += () => { _wordWrap = wrapCb.Value; RefreshDiffView(); };
wrapGroup.Add( wrapCb );
wrapGroup.Add( new Label( "Word Wrap", parent ) );
toolbar.AddStretchCell();
}
private async void RefreshDiffView()
{
try
{
_contentArea.DestroyChildren();
_lastVerticalScroll = -1;
var header = _contentArea.Layout.AddRow();
header.Margin = new Margin(0, 0, 0, 4);
header.Add(
new Label("<b><font color='#ff7777'> - PREVIOUS SNAPSHOT</font></b>"),
1
);
header.Add(new Label("<b><font color='#77ff77'> + CURRENT VERSION</font></b>"), 1);
var loadingLabel = new Label("<i>Computing diff, please wait...</i>");
_contentArea.Layout.Add(loadingLabel);
var compareRow = _contentArea.Layout.AddRow();
compareRow.Spacing = 10;
var (oldHtml, newHtml) = await Task.Run(() =>
GenerateSplitDiffHtml(_oldContent, _newContent)
);
loadingLabel.Destroy();
_leftScroll = CreateDiffSide(_contentArea, oldHtml);
_rightScroll = CreateDiffSide(_contentArea, newHtml);
compareRow.Add(_leftScroll, 1);
compareRow.Add(_rightScroll, 1);
}
catch (Exception e)
{
Log.Error($"Failed to refresh diff view: {e.Message}");
var errorLabel = new Label($"Error generating diff: {e.Message}");
_contentArea.Layout.Add(errorLabel);
}
}
private ScrollArea CreateDiffSide( Widget parent, string html )
{
var scroll = new ScrollArea( parent );
var container = new Widget( scroll );
container.Layout = Layout.Column();
var label = new Label( html, container );
label.WordWrap = _wordWrap;
container.Layout.Add( label );
container.Layout.AddStretchCell();
scroll.Canvas = container;
return scroll;
}
[EditorEvent.Frame]
public void OnFrame()
{
if ( !_syncScrolling || _leftScroll == null || _rightScroll == null ) return;
int leftV = _leftScroll.VerticalScrollbar.Value;
int rightV = _rightScroll.VerticalScrollbar.Value;
if ( leftV != _lastVerticalScroll )
{
_rightScroll.VerticalScrollbar.Value = leftV;
_lastVerticalScroll = leftV;
}
else if ( rightV != _lastVerticalScroll )
{
_leftScroll.VerticalScrollbar.Value = rightV;
_lastVerticalScroll = rightV;
}
}
private (string oldHtml, string newHtml) GenerateSplitDiffHtml( string oldText, string newText )
{
var oldLines = oldText.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None );
var newLines = newText.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None );
var diffs = ComputeLineDiff( oldLines, newLines );
string wrapStyle = _wordWrap ? "white-space: pre-wrap; word-break: break-all;" : "white-space: pre;";
string tableBase = $"<table border='0' cellspacing='0' cellpadding='0' style='font-family: Consolas, monospace; font-size: 12px; width: 100%; {wrapStyle} line-height: 1.4;'>";
var oldRes = new System.Text.StringBuilder( tableBase );
var newRes = new System.Text.StringBuilder( tableBase );
int oPtr = 1; int nPtr = 1;
string gutter = "background-color: #1d1d1d; color: #555; width: 40px; text-align: right; padding-right: 8px; border-right: 1px solid #333;";
string content = "padding-left: 8px;";
string spacerRow = $"<tr><td style='{gutter}'> </td><td style='background-color: #1a1a1a; {content}'> </td></tr>";
for ( int i = 0; i < diffs.Count; i++ )
{
var d = diffs[i];
if ( _lineLeveling && d.Type == DiffType.Delete && i + 1 < diffs.Count && diffs[i + 1].Type == DiffType.Add )
{
var next = diffs[i + 1];
var (hOld, hNew) = DiffCharacters( d.Text, next.Text );
oldRes.Append( $"<tr style='background-color: #321e1e;'><td style='{gutter}'>{oPtr++}</td><td style='{content}'>{hOld}</td></tr>" );
newRes.Append( $"<tr style='background-color: #12261e;'><td style='{gutter}'>{nPtr++}</td><td style='{content}'>{hNew}</td></tr>" );
i++;
continue;
}
string esc = System.Security.SecurityElement.Escape( d.Text );
if ( d.Type == DiffType.Match )
{
oldRes.Append( $"<tr><td style='{gutter}'>{oPtr++}</td><td style='{content}'>{esc}</td></tr>" );
newRes.Append( $"<tr><td style='{gutter}'>{nPtr++}</td><td style='{content}'>{esc}</td></tr>" );
}
else if ( d.Type == DiffType.Add )
{
if ( _lineLeveling ) oldRes.Append( spacerRow );
newRes.Append( $"<tr style='background-color: #12261e;'><td style='{gutter}'>{nPtr++}</td><td style='{content}'>{esc}</td></tr>" );
}
else if ( d.Type == DiffType.Delete )
{
oldRes.Append( $"<tr style='background-color: #321e1e;'><td style='{gutter}'>{oPtr++}</td><td style='{content}'>{esc}</td></tr>" );
if ( _lineLeveling ) newRes.Append( spacerRow );
}
}
oldRes.Append( "</table>" );
newRes.Append( "</table>" );
return (oldRes.ToString(), newRes.ToString());
}
private enum DiffType { Match, Add, Delete }
private struct LineDiff { public DiffType Type; public string Text; }
private List<LineDiff> ComputeLineDiff( string[] oldLines, string[] newLines )
{
int n = oldLines.Length, m = newLines.Length;
int[,] lp = new int[n + 1, m + 1];
for ( int i = 1; i <= n; i++ )
for ( int j = 1; j <= m; j++ )
lp[i, j] = (oldLines[i - 1] == newLines[j - 1]) ? lp[i - 1, j - 1] + 1 : Math.Max( lp[i - 1, j], lp[i, j - 1] );
var res = new List<LineDiff>();
int x = n, y = m;
while ( x > 0 || y > 0 )
{
if ( x > 0 && y > 0 && oldLines[x - 1] == newLines[y - 1] )
{
res.Add( new LineDiff { Type = DiffType.Match, Text = oldLines[x - 1] } );
x--; y--;
}
else if ( y > 0 && (x == 0 || lp[x, y - 1] >= lp[x - 1, y]) )
{
res.Add( new LineDiff { Type = DiffType.Add, Text = newLines[y - 1] } );
y--;
}
else
{
res.Add( new LineDiff { Type = DiffType.Delete, Text = oldLines[x - 1] } );
x--;
}
}
res.Reverse();
return res;
}
private (string oldOut, string newOut) DiffCharacters( string oldLine, string newLine )
{
int start = 0;
while ( start < oldLine.Length && start < newLine.Length && oldLine[start] == newLine[start] ) start++;
int oEnd = oldLine.Length - 1, nEnd = newLine.Length - 1;
while ( oEnd >= start && nEnd >= start && oldLine[oEnd] == newLine[nEnd] ) { oEnd--; nEnd--; }
string sOld = System.Security.SecurityElement.Escape( oldLine.Substring( 0, start ) );
string mOld = $"<span style='background-color: #792e2d; color: white;'>{System.Security.SecurityElement.Escape( oldLine.Substring( start, Math.Max( 0, oEnd - start + 1 ) ) )}</span>";
string eOld = System.Security.SecurityElement.Escape( oldLine.Substring( oEnd + 1 ) );
string sNew = System.Security.SecurityElement.Escape( newLine.Substring( 0, start ) );
string mNew = $"<span style='background-color: #1d572d; color: white;'>{System.Security.SecurityElement.Escape( newLine.Substring( start, Math.Max( 0, nEnd - start + 1 ) ) )}</span>";
string eNew = System.Security.SecurityElement.Escape( newLine.Substring( nEnd + 1 ) );
return (sOld + mOld + eOld, sNew + mNew + eNew);
}
}
public class ClickableLabel : Label
{
public Action DoubleClicked { get; set; }
public Action<Vector2> RightClicked { get; set; }
public ClickableLabel( string text, Widget parent ) : base( text, parent ) { }
protected override void OnDoubleClick( MouseEvent e ) { base.OnDoubleClick( e ); DoubleClicked?.Invoke(); }
protected override void OnContextMenu( ContextMenuEvent e ) { base.OnContextMenu( e ); RightClicked?.Invoke( e.ScreenPosition ); e.Accepted = true; }
}
private void ShowOperationNotification( string title, string subtitle, string icon, Color borderColor, float seconds = 2.5f )
{
var toast = new OperationToast { Title = title, Subtitle = subtitle, Icon = icon, BorderColor = borderColor, DrawTimer = false, IsRunning = false, FixedWidth = 320, FixedHeight = 72 };
ToastManager.Remove( toast, seconds );
}
private class OperationToast : ToastWidget
{
public override bool WantsVisible => EditorPreferences.NotificationPopups;
protected override void OnPaint() { if ( !EditorPreferences.NotificationPopups ) return; base.OnPaint(); }
}
}