diff --git a/ILSpy.Tests/Compare/CompareContextMenuTests.cs b/ILSpy.Tests/Compare/CompareContextMenuTests.cs
new file mode 100644
index 000000000..c2cf5aa1a
--- /dev/null
+++ b/ILSpy.Tests/Compare/CompareContextMenuTests.cs
@@ -0,0 +1,117 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpyX.TreeView;
+
+using ILSpy;
+using ILSpy.AppEnv;
+using ILSpy.Commands;
+using ILSpy.Compare;
+using ILSpy.TreeNodes;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Compare;
+
+///
+/// Pins the right-click → Compare flow: visibility gate (exactly two valid assembly
+/// nodes), and that Execute opens a fresh tab containing a
+/// with a populated root.
+///
+[TestFixture]
+public class CompareContextMenuTests
+{
+ [AvaloniaTest]
+ public async Task Compare_Is_Visible_When_Two_Assemblies_Are_Selected()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2);
+
+ var entry = AppComposition.Current.GetExport()
+ .Entries.Single(e => e.Metadata.Header == "Compare...").Value;
+ var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
+ .Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList();
+ assemblies.Should().HaveCount(2, "the fixture must load at least two valid assemblies");
+
+ var nodes = assemblies.Select(a =>
+ (SharpTreeNode)vm.AssemblyTreeModel.FindNode(a.ShortName)).ToArray();
+ entry.IsVisible(new TextViewContext { SelectedTreeNodes = nodes }).Should().BeTrue();
+ entry.IsEnabled(new TextViewContext { SelectedTreeNodes = nodes }).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Compare_Is_Hidden_For_Single_Selection()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ var entry = AppComposition.Current.GetExport()
+ .Entries.Single(e => e.Metadata.Header == "Compare...").Value;
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var node = vm.AssemblyTreeModel.FindNode(coreLibName);
+ entry.IsVisible(new TextViewContext {
+ SelectedTreeNodes = new[] { (SharpTreeNode)node }
+ }).Should().BeFalse(
+ "Compare needs exactly two assemblies — single selection must hide the entry");
+ }
+
+ [AvaloniaTest]
+ public async Task Execute_Opens_A_Tab_Containing_A_CompareTabPageModel_With_Populated_Tree()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2);
+
+ var entry = AppComposition.Current.GetExport()
+ .Entries.Single(e => e.Metadata.Header == "Compare...").Value;
+ var dockWorkspace = AppComposition.Current.GetExport();
+ var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
+ .Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList();
+ var nodes = assemblies.Select(a =>
+ (SharpTreeNode)vm.AssemblyTreeModel.FindNode(a.ShortName)).ToArray();
+
+ entry.Execute(new TextViewContext { SelectedTreeNodes = nodes });
+
+ // The new tab's Content is the CompareTabPageModel. Walk the dock's tabs and find it.
+ var page = dockWorkspace.Documents?.VisibleDockables?
+ .OfType()
+ .Select(t => t.Content)
+ .OfType()
+ .FirstOrDefault();
+ Assert.That(page, Is.Not.Null, "a ContentTabPage with CompareTabPageModel content must exist");
+ ReferenceEquals(page!.LeftAssembly, assemblies[0]).Should().BeTrue(
+ "first-selected node becomes the left-hand assembly of the comparison");
+ ReferenceEquals(page.RightAssembly, assemblies[1]).Should().BeTrue(
+ "second-selected node becomes the right-hand assembly");
+ ((object?)page.RootEntry).Should().NotBeNull("the merged-tree root must be set at construction");
+ }
+}
diff --git a/ILSpy.Tests/Compare/CompareEngineTests.cs b/ILSpy.Tests/Compare/CompareEngineTests.cs
new file mode 100644
index 000000000..2e847bd7c
--- /dev/null
+++ b/ILSpy.Tests/Compare/CompareEngineTests.cs
@@ -0,0 +1,136 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+using System.Linq;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ILSpy.Compare;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Compare;
+
+///
+/// Unit tests for the assembly-comparison engine. The diff logic is pure data — synthetic
+/// trees, no decompiler required — so each scenario runs in
+/// milliseconds.
+///
+[TestFixture]
+public class CompareEngineTests
+{
+ static Entry Leaf(string signature) => new Entry { Signature = signature, Entity = null!, };
+
+ static Entry Branch(string signature, params Entry[] children) => new Entry {
+ Signature = signature,
+ Entity = null!,
+ Children = children.ToList(),
+ };
+
+ [Test]
+ public void CalculateDiff_Pairs_Identical_Signatures()
+ {
+ var left = new List { Leaf("M1"), Leaf("M2") };
+ var right = new List { Leaf("M1"), Leaf("M2") };
+ var diff = CompareEngine.CalculateDiff(left, right);
+ diff.Should().HaveCount(2);
+ diff.Should().OnlyContain(p => p.Left != null && p.Right != null);
+ diff.All(p => p.Left!.Kind == DiffKind.None).Should().BeTrue();
+ }
+
+ [Test]
+ public void CalculateDiff_Marks_Left_Only_As_Remove()
+ {
+ var left = new List { Leaf("Stays"), Leaf("Gone") };
+ var right = new List { Leaf("Stays") };
+ var diff = CompareEngine.CalculateDiff(left, right);
+ var gone = diff.Single(p => p.Left?.Signature == "Gone");
+ ((object?)gone.Right).Should().BeNull();
+ gone.Left!.Kind.Should().Be(DiffKind.Remove);
+ }
+
+ [Test]
+ public void CalculateDiff_Marks_Right_Only_As_Add()
+ {
+ var left = new List { Leaf("Stays") };
+ var right = new List { Leaf("Stays"), Leaf("New") };
+ var diff = CompareEngine.CalculateDiff(left, right);
+ var added = diff.Single(p => p.Right?.Signature == "New");
+ ((object?)added.Left).Should().BeNull();
+ added.Right!.Kind.Should().Be(DiffKind.Add);
+ }
+
+ [Test]
+ public void MergeTrees_Aligns_Identical_Subtrees_And_Tags_Diff_On_Children()
+ {
+ var left = Branch("Type",
+ Leaf("Stays"),
+ Leaf("Gone"));
+ var right = Branch("Type",
+ Leaf("Stays"),
+ Leaf("New"));
+ var merged = CompareEngine.MergeTrees(left, right);
+ merged.Children.Should().NotBeNull();
+ merged.Children!.Should().HaveCount(3,
+ "the merge keeps one slot per distinct signature across both sides");
+ merged.Children.Should().Contain(c => c.Signature == "Gone" && c.Kind == DiffKind.Remove);
+ merged.Children.Should().Contain(c => c.Signature == "New" && c.Kind == DiffKind.Add);
+ merged.Children.Should().Contain(c => c.Signature == "Stays" && c.Kind == DiffKind.None);
+ }
+
+ [Test]
+ public void RecursiveKind_Summarises_All_Removed_Children_As_Remove()
+ {
+ // A branch whose every leaf is gone reads as Remove at the branch level —
+ // matches WPF's "if every child is the same change kind, propagate it".
+ var entry = Branch("T",
+ new Entry { Signature = "a", Entity = null!, Kind = DiffKind.Remove },
+ new Entry { Signature = "b", Entity = null!, Kind = DiffKind.Remove });
+ entry.RecursiveKind.Should().Be(DiffKind.Remove);
+ }
+
+ [Test]
+ public void RecursiveKind_Mixed_Changes_Becomes_Update()
+ {
+ var entry = Branch("T",
+ new Entry { Signature = "a", Entity = null!, Kind = DiffKind.Add },
+ new Entry { Signature = "b", Entity = null!, Kind = DiffKind.Remove });
+ entry.RecursiveKind.Should().Be(DiffKind.Update);
+ }
+
+ [Test]
+ public void RecursiveKind_All_Identical_Children_Is_None()
+ {
+ var entry = Branch("T",
+ new Entry { Signature = "a", Entity = null!, Kind = DiffKind.None },
+ new Entry { Signature = "b", Entity = null!, Kind = DiffKind.None });
+ entry.RecursiveKind.Should().Be(DiffKind.None);
+ }
+
+ [Test]
+ public void EntryComparer_Keys_On_Signature_String()
+ {
+ var left = Leaf("public void Foo()");
+ var right = Leaf("public void Foo()");
+ EntryComparer.Instance.Equals(left, right).Should().BeTrue();
+ EntryComparer.Instance.GetHashCode(left).Should().Be(EntryComparer.Instance.GetHashCode(right));
+ }
+}
diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml
index 417cd49cb..79a01c1b8 100644
--- a/ILSpy/App.axaml
+++ b/ILSpy/App.axaml
@@ -5,6 +5,7 @@
xmlns:tree="using:ILSpy.AssemblyTree"
xmlns:search="using:ILSpy.Search"
xmlns:analyzers="using:ILSpy.Analyzers"
+ xmlns:compare="using:ILSpy.Compare"
xmlns:textView="using:ILSpy.TextView"
xmlns:metaViews="using:ILSpy.Views"
xmlns:vm="using:ILSpy.ViewModels"
@@ -57,6 +58,9 @@
+
+
+
diff --git a/ILSpy/Commands/CompareContextMenuEntry.cs b/ILSpy/Commands/CompareContextMenuEntry.cs
new file mode 100644
index 000000000..42ce41f86
--- /dev/null
+++ b/ILSpy/Commands/CompareContextMenuEntry.cs
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Composition;
+
+using ICSharpCode.ILSpyX.TreeView;
+
+using ILSpy.AppEnv;
+using ILSpy.Compare;
+using ILSpy.Docking;
+using ILSpy.TreeNodes;
+
+namespace ILSpy.Commands
+{
+ ///
+ /// Right-click → "Compare…" on exactly two valid managed assemblies in the tree.
+ /// Opens a fresh tab whose content is a driven by
+ /// . Order matters: the first-selected node becomes the
+ /// "left" side, the second becomes the "right".
+ ///
+ [ExportContextMenuEntry(Header = "Compare...", Order = 9999)]
+ [Shared]
+ public sealed class CompareContextMenuEntry : IContextMenuEntry
+ {
+ public bool IsEnabled(TextViewContext context) => IsVisible(context);
+
+ public bool IsVisible(TextViewContext context)
+ => context.SelectedTreeNodes is
+ [AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true },
+ AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }];
+
+ public void Execute(TextViewContext context)
+ {
+ if (context.SelectedTreeNodes is not
+ [AssemblyTreeNode left, AssemblyTreeNode right])
+ return;
+
+ var dockWorkspace = TryGetExport();
+ if (dockWorkspace == null)
+ return;
+ var pane = new CompareTabPageModel(left.LoadedAssembly, right.LoadedAssembly);
+ // OpenNewTab wraps the content viewmodel in a ContentTabPage; the DataTemplate
+ // for CompareTabPageModel routes through CompareView. Pass null for sourceNode
+ // so the new tab isn't anchored to either assembly's tree node — the compare
+ // view is a pair, not a single-node decompile result.
+ dockWorkspace.OpenNewTab(pane);
+ }
+
+ static T? TryGetExport() where T : class
+ {
+ try
+ { return AppComposition.Current.GetExport(); }
+ catch { return null; }
+ }
+ }
+}
diff --git a/ILSpy/CompareEngine.cs b/ILSpy/CompareEngine.cs
new file mode 100644
index 000000000..aaf58ff5c
--- /dev/null
+++ b/ILSpy/CompareEngine.cs
@@ -0,0 +1,303 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Reflection.Metadata;
+
+using ICSharpCode.Decompiler.CSharp.OutputVisitor;
+using ICSharpCode.Decompiler.Output;
+using ICSharpCode.Decompiler.TypeSystem;
+
+namespace ILSpy.Compare
+{
+ ///
+ /// Builds a merged tree from two assemblies' type systems. The
+ /// process is:
+ ///
+ /// Walk each assembly's public surface () into a
+ /// per-side tree keyed on C#-formatted signature strings.
+ /// Recursively merge the two trees () — paired entries
+ /// get an ; left-only entries are tagged
+ /// ; right-only are tagged .
+ ///
+ /// Signatures are the keying mechanism because metadata tokens don't survive
+ /// re-compilation; wraps the string-equality check.
+ ///
+ public static class CompareEngine
+ {
+ ///
+ /// Walks 's public types and members and returns
+ /// (flat-list-of-leaves, namespace-rooted-tree). The flat list is for the side
+ /// that drives diff pairing in ; the tree is the
+ /// hierarchical view the UI consumes.
+ ///
+ public static (List AllEntries, Entry Root) CreateEntityTree(ICompilation typeSystem)
+ {
+ var module = (MetadataModule)typeSystem.MainModule!;
+ var metadata = module.MetadataFile!.Metadata;
+ var ambience = new CSharpAmbience {
+ ConversionFlags = ConversionFlags.All & ~ConversionFlags.ShowDeclaringType,
+ };
+
+ var results = new List();
+ var typeEntries = new Dictionary();
+ var namespaceEntries = new Dictionary(System.StringComparer.Ordinal);
+ var root = new Entry { Entity = module, Signature = module.FullAssemblyName };
+
+ Entry? TryCreateEntry(IEntity entity)
+ {
+ if (entity.EffectiveAccessibility() != Accessibility.Public)
+ return null;
+ Entry? parent = null;
+ // Nested entities are only included when their declaring type is itself a
+ // recorded entry (i.e. public). Skipping otherwise drops the whole subtree —
+ // matching the WPF behaviour that "compare" shows public-API only.
+ if (entity.DeclaringTypeDefinition != null
+ && !typeEntries.TryGetValue((TypeDefinitionHandle)entity.DeclaringTypeDefinition.MetadataToken, out parent))
+ {
+ return null;
+ }
+ var entry = new Entry {
+ Signature = ambience.ConvertSymbol(entity),
+ Entity = entity,
+ Parent = parent,
+ };
+ if (parent != null)
+ {
+ parent.Children ??= new List();
+ parent.Children.Add(entry);
+ }
+ return entry;
+ }
+
+ foreach (var typeDefHandle in metadata.TypeDefinitions)
+ {
+ var typeDef = module.GetDefinition(typeDefHandle);
+ if (typeDef.EffectiveAccessibility() != Accessibility.Public)
+ continue;
+ var entry = typeEntries[typeDefHandle] = new Entry {
+ Signature = ambience.ConvertSymbol(typeDef),
+ Entity = typeDef,
+ };
+ if (typeDef.DeclaringType == null)
+ {
+ if (!namespaceEntries.TryGetValue(typeDef.Namespace, out var nsEntry))
+ {
+ namespaceEntries[typeDef.Namespace] = nsEntry = new Entry {
+ Parent = root,
+ Signature = typeDef.Namespace,
+ Entity = ResolveNamespace(typeDef.Namespace, typeDef.ParentModule!)!,
+ };
+ root.Children ??= new List();
+ root.Children.Add(nsEntry);
+ }
+ entry.Parent = nsEntry;
+ nsEntry.Children ??= new List();
+ nsEntry.Children.Add(entry);
+ }
+ }
+
+ foreach (var fieldHandle in metadata.FieldDefinitions)
+ {
+ var entry = TryCreateEntry(module.GetDefinition(fieldHandle));
+ if (entry != null)
+ results.Add(entry);
+ }
+ foreach (var eventHandle in metadata.EventDefinitions)
+ {
+ var entry = TryCreateEntry(module.GetDefinition(eventHandle));
+ if (entry != null)
+ results.Add(entry);
+ }
+ foreach (var propertyHandle in metadata.PropertyDefinitions)
+ {
+ var entry = TryCreateEntry(module.GetDefinition(propertyHandle));
+ if (entry != null)
+ results.Add(entry);
+ }
+ foreach (var methodHandle in metadata.MethodDefinitions)
+ {
+ var methodDef = module.GetDefinition(methodHandle);
+ // Accessor methods (get_X / set_X / add_X / remove_X) are folded into their
+ // owning property / event row in the tree; comparing them separately would
+ // double-count every property as four entries.
+ if (methodDef.AccessorOwner != null)
+ continue;
+ var entry = TryCreateEntry(methodDef);
+ if (entry != null)
+ results.Add(entry);
+ }
+
+ return (results, root);
+ }
+
+ static INamespace? ResolveNamespace(string namespaceName, IModule module)
+ {
+ INamespace current = module.RootNamespace;
+ var parts = namespaceName.Split('.');
+ for (int i = 0; i < parts.Length; i++)
+ {
+ if (i == 0 && string.IsNullOrEmpty(parts[i]))
+ continue;
+ var next = current.GetChildNamespace(parts[i]);
+ if (next != null)
+ current = next;
+ else
+ return null;
+ }
+ return current;
+ }
+
+ ///
+ /// Recursively merges two parallel entry trees into a single one. Each interior
+ /// node's children are paired by ; unmatched children
+ /// keep their leaf tag (Add / Remove) so the recursive
+ /// summary on ancestors reflects the actual change shape.
+ ///
+ public static Entry MergeTrees(Entry a, Entry b)
+ {
+ var m = new Entry {
+ Entity = a.Entity,
+ OtherEntity = b.Entity,
+ Signature = a.Signature,
+ };
+ if (a.Children?.Count > 0 && b.Children?.Count > 0)
+ {
+ var diff = CalculateDiff(a.Children, b.Children);
+ m.Children ??= new List();
+ foreach (var (left, right) in diff)
+ {
+ if (left != null && right != null)
+ m.Children.Add(MergeTrees(left, right));
+ else if (left != null)
+ m.Children.Add(left);
+ else if (right != null)
+ m.Children.Add(right);
+ else
+ Debug.Fail("CalculateDiff returned a (null, null) pair");
+ m.Children[^1].Parent = m;
+ }
+ }
+ else if (a.Children?.Count > 0)
+ {
+ m.Children ??= new List();
+ foreach (var child in a.Children)
+ {
+ child.Parent = m;
+ m.Children.Add(child);
+ }
+ }
+ else if (b.Children?.Count > 0)
+ {
+ m.Children ??= new List();
+ foreach (var child in b.Children)
+ {
+ child.Parent = m;
+ m.Children.Add(child);
+ }
+ }
+ return m;
+ }
+
+ ///
+ /// Pairs and by signature.
+ /// Returns one (Left, Right) tuple per slot: both non-null when matched;
+ /// only Left → Remove (and tags the leaf ); only
+ /// Right → Add (and tags the leaf ).
+ ///
+ public static List<(Entry? Left, Entry? Right)> CalculateDiff(List left, List right)
+ {
+ var leftMap = new Dictionary>();
+ var rightMap = new Dictionary>();
+ foreach (var item in left)
+ {
+ if (leftMap.TryGetValue(item.Signature, out var bucket))
+ bucket.Add(item);
+ else
+ leftMap[item.Signature] = new List { item };
+ }
+ foreach (var item in right)
+ {
+ if (rightMap.TryGetValue(item.Signature, out var bucket))
+ bucket.Add(item);
+ else
+ rightMap[item.Signature] = new List { item };
+ }
+
+ var results = new List<(Entry? Left, Entry? Right)>();
+ foreach (var (key, items) in leftMap)
+ {
+ if (rightMap.TryGetValue(key, out var rightEntries))
+ {
+ foreach (var item in items)
+ {
+ var other = rightEntries.Find(r => EntryComparer.Instance.Equals(r, item));
+ results.Add((item, other));
+ if (other == null)
+ SetKind(item, DiffKind.Remove);
+ }
+ }
+ else
+ {
+ foreach (var item in items)
+ {
+ SetKind(item, DiffKind.Remove);
+ results.Add((item, null));
+ }
+ }
+ }
+ foreach (var (key, items) in rightMap)
+ {
+ if (leftMap.TryGetValue(key, out var leftEntries))
+ {
+ foreach (var item in items)
+ {
+ if (!leftEntries.Exists(l => EntryComparer.Instance.Equals(l, item)))
+ {
+ results.Add((null, item));
+ SetKind(item, DiffKind.Add);
+ }
+ }
+ }
+ else
+ {
+ foreach (var item in items)
+ {
+ SetKind(item, DiffKind.Add);
+ results.Add((null, item));
+ }
+ }
+ }
+ return results;
+ }
+
+ static void SetKind(Entry item, DiffKind kind)
+ {
+ if (item.Children?.Count > 0)
+ {
+ foreach (var child in item.Children)
+ SetKind(child, kind);
+ }
+ else
+ {
+ item.Kind = kind;
+ }
+ }
+ }
+}
diff --git a/ILSpy/Entry.cs b/ILSpy/Entry.cs
new file mode 100644
index 000000000..0b9ec22b3
--- /dev/null
+++ b/ILSpy/Entry.cs
@@ -0,0 +1,118 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+namespace ILSpy.Compare
+{
+ ///
+ /// How an compares between the left and right assemblies. Stored on
+ /// leaf entries by ; interior nodes derive a recursive
+ /// summary via .
+ ///
+ public enum DiffKind
+ {
+ /// Identical on both sides.
+ None = ' ',
+ /// Only present on the right.
+ Add = '+',
+ /// Only present on the left.
+ Remove = '-',
+ /// Present on both but the bodies differ.
+ Update = '~',
+ }
+
+ ///
+ /// One node in the merged comparison tree. The is the symbol on
+ /// the left assembly; is the right-side counterpart when one
+ /// exists. is the C#-formatted text the engine keys on for
+ /// matching pairs across sides.
+ ///
+ [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
+ public sealed class Entry
+ {
+ DiffKind? kind;
+
+ ///
+ /// Per-node diff kind for leaves; for interior nodes, summarises the children:
+ /// all-Add → Add; all-Remove → Remove; any-non-None → Update; otherwise None.
+ ///
+ public DiffKind RecursiveKind {
+ get {
+ if (kind != null)
+ return kind.Value;
+ if (Children == null)
+ return DiffKind.None;
+ int addCount = 0, removeCount = 0, updateCount = 0;
+ foreach (var item in Children)
+ {
+ switch (item.RecursiveKind)
+ {
+ case DiffKind.Add:
+ addCount++;
+ break;
+ case DiffKind.Remove:
+ removeCount++;
+ break;
+ case DiffKind.Update:
+ updateCount++;
+ break;
+ }
+ }
+ if (addCount == Children.Count)
+ return DiffKind.Add;
+ if (removeCount == Children.Count)
+ return DiffKind.Remove;
+ if (addCount > 0 || removeCount > 0 || updateCount > 0)
+ return DiffKind.Update;
+ return DiffKind.None;
+ }
+ }
+
+ public DiffKind Kind {
+ get => kind ?? DiffKind.None;
+ set => kind = value;
+ }
+
+ public required string Signature { get; init; }
+ public required ISymbol Entity { get; init; }
+ public ISymbol? OtherEntity { get; init; }
+ public Entry? Parent { get; set; }
+ public List? Children { get; set; }
+
+ string GetDebuggerDisplay() => $"Entry{Kind} {Entity?.ToString() ?? Signature}";
+ }
+
+ ///
+ /// Two entries match when their string is equal — that's
+ /// the only stable key across an assembly version, since metadata tokens are not
+ /// preserved across rebuilds.
+ ///
+ public sealed class EntryComparer : IEqualityComparer
+ {
+ public static readonly EntryComparer Instance = new();
+
+ public bool Equals(Entry? x, Entry? y) => x?.Signature == y?.Signature;
+
+ public int GetHashCode([DisallowNull] Entry obj) => obj.Signature.GetHashCode();
+ }
+}
diff --git a/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs
new file mode 100644
index 000000000..9685a5e9c
--- /dev/null
+++ b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs
@@ -0,0 +1,131 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+
+using Avalonia.Media;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.Output;
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX;
+
+using ILSpy.Languages;
+using ILSpy.TreeNodes;
+
+namespace ILSpy.Compare
+{
+ ///
+ /// Tree-node façade over a merged . The text combines both sides'
+ /// formatted entity strings (with a " -> " separator when they differ); the
+ /// background brush is a light tint that mirrors the diff kind (green for additions,
+ /// pink for removals, blue for updates). Identical entries render with a transparent
+ /// background and stay hidden unless the toolbar's "Show Identical" toggle is on.
+ ///
+ public sealed class ComparisonEntryTreeNode : ILSpyTreeNode
+ {
+ readonly Entry entry;
+ readonly CompareTabPageModel pane;
+
+ internal Entry Entry => entry;
+
+ public ComparisonEntryTreeNode(Entry entry, CompareTabPageModel pane)
+ {
+ this.entry = entry;
+ this.pane = pane;
+ LazyLoading = entry.Children != null;
+ }
+
+ protected override void LoadChildren()
+ {
+ if (entry.Children == null)
+ return;
+ // Order: changes first (Add/Remove/Update lexically before None via the negated
+ // enum cast), then by SymbolKind so types group together, then alphabetically
+ // by signature. Matches the WPF view's display order.
+ foreach (var item in entry.Children.OrderBy(e => (-(int)e.RecursiveKind, e.Entity.SymbolKind, e.Signature)))
+ Children.Add(new ComparisonEntryTreeNode(item, pane));
+ }
+
+ public override object Text {
+ get {
+ var leftText = FormatEntity(entry.Entity);
+ var rightText = FormatEntity(entry.OtherEntity);
+ return leftText + (rightText != null && leftText != rightText ? " -> " + rightText : "");
+ }
+ }
+
+ string? FormatEntity(ISymbol? symbol) => symbol switch {
+ ITypeDefinition t => Language.TypeToString(t, ConversionFlags.None),
+ IEntity e => Language.EntityToString(e,
+ ConversionFlags.All & ~(ConversionFlags.ShowDeclaringType
+ | ConversionFlags.UseFullyQualifiedEntityNames
+ | ConversionFlags.UseFullyQualifiedTypeNames)),
+ INamespace n => n.FullName,
+ IModule m => m.FullAssemblyName,
+ _ => null,
+ };
+
+ public override object Icon => entry.Entity switch {
+ ITypeDefinition t => TypeIconForKind(t),
+ IMethod => ILSpy.Images.Images.Method,
+ IField => ILSpy.Images.Images.Field,
+ IProperty => ILSpy.Images.Images.Property,
+ IEvent => ILSpy.Images.Images.Event,
+ INamespace => ILSpy.Images.Images.Namespace,
+ IModule => ILSpy.Images.Images.Assembly,
+ _ => ILSpy.Images.Images.Class,
+ };
+
+ static object TypeIconForKind(ITypeDefinition t) => t.Kind switch {
+ TypeKind.Interface => ILSpy.Images.Images.Interface,
+ TypeKind.Struct => ILSpy.Images.Images.Struct,
+ TypeKind.Enum => ILSpy.Images.Images.Enum,
+ TypeKind.Delegate => ILSpy.Images.Images.Delegate,
+ _ => ILSpy.Images.Images.Class,
+ };
+
+ public override void Decompile(Language language, ICSharpCode.Decompiler.ITextOutput output, DecompilationOptions options)
+ {
+ // Compare nodes aren't decompilable on their own — they're a diff overlay.
+ // Clicking a row jumps to the underlying entity in the assembly tree, which
+ // then drives the normal decompile flow on whichever tab the user picks.
+ }
+
+ public override FilterResult Filter(LanguageSettings settings)
+ {
+ // Hide identical rows unless the toolbar toggle says otherwise. Recursive
+ // because a parent node whose ALL descendants are unchanged should also hide.
+ return pane.ShowIdentical || entry.RecursiveKind != DiffKind.None
+ ? FilterResult.Match
+ : FilterResult.Hidden;
+ }
+
+ ///
+ /// Pastel-tint background per diff kind: green = added on the right, pink = removed
+ /// from the right (i.e. only on the left), blue = signatures match but contents
+ /// differ. Identical rows fall through to the parent background.
+ ///
+ public IBrush BackgroundBrush => entry.RecursiveKind switch {
+ DiffKind.Add => new SolidColorBrush(Color.FromArgb(0xFF, 0x90, 0xEE, 0x90)),
+ DiffKind.Remove => new SolidColorBrush(Color.FromArgb(0xFF, 0xFF, 0xB6, 0xC1)),
+ DiffKind.Update => new SolidColorBrush(Color.FromArgb(0xFF, 0xAD, 0xD8, 0xE6)),
+ _ => Brushes.Transparent,
+ };
+ }
+}
diff --git a/ILSpy/ViewModels/CompareTabPageModel.cs b/ILSpy/ViewModels/CompareTabPageModel.cs
new file mode 100644
index 000000000..7445e5b07
--- /dev/null
+++ b/ILSpy/ViewModels/CompareTabPageModel.cs
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+using ICSharpCode.ILSpyX;
+
+using ILSpy.ViewModels;
+
+namespace ILSpy.Compare
+{
+ ///
+ /// Tab content for the assembly-comparison view. Constructed by
+ /// when the user right-clicks two
+ /// assemblies and picks "Compare…". Static content (no language switching, no
+ /// re-decompile) — the merge is computed once at construction.
+ ///
+ public sealed partial class CompareTabPageModel : TabPageModel
+ {
+ readonly LoadedAssembly leftAssembly;
+ readonly LoadedAssembly rightAssembly;
+
+ [ObservableProperty]
+ private ComparisonEntryTreeNode rootEntry;
+
+ ///
+ /// When true, identical rows surface in the tree. The default false hides them so
+ /// only the actual differences are visible — the typical use case for "what changed
+ /// between these two versions?"
+ ///
+ [ObservableProperty]
+ private bool showIdentical;
+
+ public LoadedAssembly LeftAssembly => leftAssembly;
+ public LoadedAssembly RightAssembly => rightAssembly;
+
+ public CompareTabPageModel(LoadedAssembly left, LoadedAssembly right)
+ {
+ leftAssembly = left;
+ rightAssembly = right;
+ Title = $"Compare {left.Text} - {right.Text}";
+
+ var leftTree = CompareEngine.CreateEntityTree(left.GetTypeSystemOrNull()!);
+ var rightTree = CompareEngine.CreateEntityTree(right.GetTypeSystemOrNull()!);
+ var merged = CompareEngine.MergeTrees(leftTree.Root, rightTree.Root);
+ rootEntry = new ComparisonEntryTreeNode(merged, this);
+
+ ExpandAllCommand = new RelayCommand(OnExpandAll);
+ }
+
+ public IRelayCommand ExpandAllCommand { get; }
+
+ void OnExpandAll()
+ {
+ ExpandRecursive(RootEntry);
+ }
+
+ static void ExpandRecursive(ComparisonEntryTreeNode node)
+ {
+ node.IsExpanded = true;
+ node.EnsureLazyChildren();
+ foreach (var child in node.Children)
+ if (child is ComparisonEntryTreeNode c)
+ ExpandRecursive(c);
+ }
+
+ partial void OnShowIdenticalChanged(bool value)
+ {
+ // Re-apply the filter cascade to refresh hidden/visible state — the existing
+ // ApplyFilterToChild path looks at the new ShowIdentical flag through
+ // ComparisonEntryTreeNode.Filter.
+ RootEntry.EnsureChildrenFiltered();
+ }
+ }
+}
diff --git a/ILSpy/Views/CompareView.axaml b/ILSpy/Views/CompareView.axaml
new file mode 100644
index 000000000..c67d927f4
--- /dev/null
+++ b/ILSpy/Views/CompareView.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ILSpy/Views/CompareView.axaml.cs b/ILSpy/Views/CompareView.axaml.cs
new file mode 100644
index 000000000..0d627e5a8
--- /dev/null
+++ b/ILSpy/Views/CompareView.axaml.cs
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using Avalonia.Controls;
+
+namespace ILSpy.Compare
+{
+ public partial class CompareView : UserControl
+ {
+ public CompareView()
+ {
+ InitializeComponent();
+ }
+ }
+}