mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Brings the assembly-comparison feature across. Right-click two valid managed assemblies in the tree → "Compare..." → fresh tab opens with a diff tree: identical entries hidden by default, additions tinted green, removals pink, signature-matched-but-different rows blue. Identical entries surface via the "Show identical entries" toolbar checkbox.pull/3755/head
10 changed files with 1051 additions and 0 deletions
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the right-click → Compare flow: visibility gate (exactly two valid assembly
|
||||||
|
/// nodes), and that <c>Execute</c> opens a fresh tab containing a
|
||||||
|
/// <see cref="CompareTabPageModel"/> with a populated root.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture] |
||||||
|
public class CompareContextMenuTests |
||||||
|
{ |
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Compare_Is_Visible_When_Two_Assemblies_Are_Selected() |
||||||
|
{ |
||||||
|
var window = AppComposition.Current.GetExport<MainWindow>(); |
||||||
|
window.Show(); |
||||||
|
var vm = (MainWindowViewModel)window.DataContext!; |
||||||
|
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2); |
||||||
|
|
||||||
|
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>() |
||||||
|
.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<AssemblyTreeNode>(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<MainWindow>(); |
||||||
|
window.Show(); |
||||||
|
var vm = (MainWindowViewModel)window.DataContext!; |
||||||
|
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||||
|
|
||||||
|
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>() |
||||||
|
.Entries.Single(e => e.Metadata.Header == "Compare...").Value; |
||||||
|
var coreLibName = typeof(object).Assembly.GetName().Name!; |
||||||
|
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(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<MainWindow>(); |
||||||
|
window.Show(); |
||||||
|
var vm = (MainWindowViewModel)window.DataContext!; |
||||||
|
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2); |
||||||
|
|
||||||
|
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>() |
||||||
|
.Entries.Single(e => e.Metadata.Header == "Compare...").Value; |
||||||
|
var dockWorkspace = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>(); |
||||||
|
var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() |
||||||
|
.Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList(); |
||||||
|
var nodes = assemblies.Select(a => |
||||||
|
(SharpTreeNode)vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(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<ContentTabPage>() |
||||||
|
.Select(t => t.Content) |
||||||
|
.OfType<CompareTabPageModel>() |
||||||
|
.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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the assembly-comparison engine. The diff logic is pure data — synthetic
|
||||||
|
/// <see cref="Entry"/> trees, no decompiler required — so each scenario runs in
|
||||||
|
/// milliseconds.
|
||||||
|
/// </summary>
|
||||||
|
[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<Entry> { Leaf("M1"), Leaf("M2") }; |
||||||
|
var right = new List<Entry> { 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<Entry> { Leaf("Stays"), Leaf("Gone") }; |
||||||
|
var right = new List<Entry> { 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<Entry> { Leaf("Stays") }; |
||||||
|
var right = new List<Entry> { 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)); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Right-click → "Compare…" on exactly two valid managed assemblies in the tree.
|
||||||
|
/// Opens a fresh tab whose content is a <see cref="CompareTabPageModel"/> driven by
|
||||||
|
/// <see cref="CompareEngine"/>. Order matters: the first-selected node becomes the
|
||||||
|
/// "left" side, the second becomes the "right".
|
||||||
|
/// </summary>
|
||||||
|
[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<DockWorkspace>(); |
||||||
|
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<T>() where T : class |
||||||
|
{ |
||||||
|
try |
||||||
|
{ return AppComposition.Current.GetExport<T>(); } |
||||||
|
catch { return null; } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Builds a merged <see cref="Entry"/> tree from two assemblies' type systems. The
|
||||||
|
/// process is:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Walk each assembly's public surface (<see cref="CreateEntityTree"/>) into a
|
||||||
|
/// per-side <see cref="Entry"/> tree keyed on C#-formatted signature strings.</item>
|
||||||
|
/// <item>Recursively merge the two trees (<see cref="MergeTrees"/>) — paired entries
|
||||||
|
/// get an <see cref="Entry.OtherEntity"/>; left-only entries are tagged
|
||||||
|
/// <see cref="DiffKind.Remove"/>; right-only are tagged <see cref="DiffKind.Add"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Signatures are the keying mechanism because metadata tokens don't survive
|
||||||
|
/// re-compilation; <see cref="EntryComparer"/> wraps the string-equality check.
|
||||||
|
/// </summary>
|
||||||
|
public static class CompareEngine |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Walks <paramref name="typeSystem"/>'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 <see cref="MergeTrees"/>; the tree is the
|
||||||
|
/// hierarchical view the UI consumes.
|
||||||
|
/// </summary>
|
||||||
|
public static (List<Entry> 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<Entry>(); |
||||||
|
var typeEntries = new Dictionary<TypeDefinitionHandle, Entry>(); |
||||||
|
var namespaceEntries = new Dictionary<string, Entry>(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<Entry>(); |
||||||
|
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<Entry>(); |
||||||
|
root.Children.Add(nsEntry); |
||||||
|
} |
||||||
|
entry.Parent = nsEntry; |
||||||
|
nsEntry.Children ??= new List<Entry>(); |
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively merges two parallel entry trees into a single one. Each interior
|
||||||
|
/// node's children are paired by <see cref="EntryComparer"/>; unmatched children
|
||||||
|
/// keep their leaf <see cref="Entry.Kind"/> tag (Add / Remove) so the recursive
|
||||||
|
/// summary on ancestors reflects the actual change shape.
|
||||||
|
/// </summary>
|
||||||
|
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<Entry>(); |
||||||
|
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<Entry>(); |
||||||
|
foreach (var child in a.Children) |
||||||
|
{ |
||||||
|
child.Parent = m; |
||||||
|
m.Children.Add(child); |
||||||
|
} |
||||||
|
} |
||||||
|
else if (b.Children?.Count > 0) |
||||||
|
{ |
||||||
|
m.Children ??= new List<Entry>(); |
||||||
|
foreach (var child in b.Children) |
||||||
|
{ |
||||||
|
child.Parent = m; |
||||||
|
m.Children.Add(child); |
||||||
|
} |
||||||
|
} |
||||||
|
return m; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pairs <paramref name="left"/> and <paramref name="right"/> by signature.
|
||||||
|
/// Returns one <c>(Left, Right)</c> tuple per slot: both non-null when matched;
|
||||||
|
/// only Left → Remove (and tags the leaf <see cref="DiffKind.Remove"/>); only
|
||||||
|
/// Right → Add (and tags the leaf <see cref="DiffKind.Add"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static List<(Entry? Left, Entry? Right)> CalculateDiff(List<Entry> left, List<Entry> right) |
||||||
|
{ |
||||||
|
var leftMap = new Dictionary<string, List<Entry>>(); |
||||||
|
var rightMap = new Dictionary<string, List<Entry>>(); |
||||||
|
foreach (var item in left) |
||||||
|
{ |
||||||
|
if (leftMap.TryGetValue(item.Signature, out var bucket)) |
||||||
|
bucket.Add(item); |
||||||
|
else |
||||||
|
leftMap[item.Signature] = new List<Entry> { item }; |
||||||
|
} |
||||||
|
foreach (var item in right) |
||||||
|
{ |
||||||
|
if (rightMap.TryGetValue(item.Signature, out var bucket)) |
||||||
|
bucket.Add(item); |
||||||
|
else |
||||||
|
rightMap[item.Signature] = new List<Entry> { 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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// How an <see cref="Entry"/> compares between the left and right assemblies. Stored on
|
||||||
|
/// leaf entries by <see cref="CompareEngine"/>; interior nodes derive a recursive
|
||||||
|
/// summary via <see cref="Entry.RecursiveKind"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum DiffKind |
||||||
|
{ |
||||||
|
/// <summary>Identical on both sides.</summary>
|
||||||
|
None = ' ', |
||||||
|
/// <summary>Only present on the right.</summary>
|
||||||
|
Add = '+', |
||||||
|
/// <summary>Only present on the left.</summary>
|
||||||
|
Remove = '-', |
||||||
|
/// <summary>Present on both but the bodies differ.</summary>
|
||||||
|
Update = '~', |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One node in the merged comparison tree. The <see cref="Entity"/> is the symbol on
|
||||||
|
/// the left assembly; <see cref="OtherEntity"/> is the right-side counterpart when one
|
||||||
|
/// exists. <see cref="Signature"/> is the C#-formatted text the engine keys on for
|
||||||
|
/// matching pairs across sides.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] |
||||||
|
public sealed class Entry |
||||||
|
{ |
||||||
|
DiffKind? kind; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-node diff kind for leaves; for interior nodes, summarises the children:
|
||||||
|
/// all-Add → Add; all-Remove → Remove; any-non-None → Update; otherwise None.
|
||||||
|
/// </summary>
|
||||||
|
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<Entry>? Children { get; set; } |
||||||
|
|
||||||
|
string GetDebuggerDisplay() => $"Entry{Kind} {Entity?.ToString() ?? Signature}"; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two entries match when their <see cref="Entry.Signature"/> string is equal — that's
|
||||||
|
/// the only stable key across an assembly version, since metadata tokens are not
|
||||||
|
/// preserved across rebuilds.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EntryComparer : IEqualityComparer<Entry> |
||||||
|
{ |
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Tree-node façade over a merged <see cref="Entry"/>. The text combines both sides'
|
||||||
|
/// formatted entity strings (with a <c>" -> "</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Tab content for the assembly-comparison view. Constructed by
|
||||||
|
/// <see cref="Commands.CompareContextMenuEntry"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CompareTabPageModel : TabPageModel |
||||||
|
{ |
||||||
|
readonly LoadedAssembly leftAssembly; |
||||||
|
readonly LoadedAssembly rightAssembly; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private ComparisonEntryTreeNode rootEntry; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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?"
|
||||||
|
/// </summary>
|
||||||
|
[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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:compare="using:ILSpy.Compare" |
||||||
|
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" |
||||||
|
x:Class="ILSpy.Compare.CompareView" |
||||||
|
x:DataType="compare:CompareTabPageModel"> |
||||||
|
<DockPanel> |
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="4"> |
||||||
|
<CheckBox Content="Show identical entries" |
||||||
|
IsChecked="{Binding ShowIdentical, Mode=TwoWay}" |
||||||
|
ToolTip.Tip="Reveal rows that are identical on both sides — by default the tree only shows actual differences." /> |
||||||
|
<Button Margin="8,0,0,0" Content="Expand all" |
||||||
|
Command="{Binding ExpandAllCommand}" |
||||||
|
ToolTip.Tip="Expand every namespace and type in the diff tree." /> |
||||||
|
</StackPanel> |
||||||
|
<DataGrid Name="DiffGrid" |
||||||
|
Classes="hierarchical" |
||||||
|
AutoGenerateColumns="False" |
||||||
|
HeadersVisibility="None" |
||||||
|
HierarchicalRowsEnabled="True" |
||||||
|
GridLinesVisibility="None" |
||||||
|
CanUserResizeColumns="False" |
||||||
|
SelectionMode="Single" |
||||||
|
ItemsSource="{Binding RootEntry.Children}"> |
||||||
|
<DataGrid.Columns> |
||||||
|
<DataGridHierarchicalColumn Header="Diff" Width="*" |
||||||
|
IsReadOnly="True" |
||||||
|
x:CompileBindings="False" |
||||||
|
Binding="{Binding Item}"> |
||||||
|
<DataGridHierarchicalColumn.CellTemplate> |
||||||
|
<DataTemplate> |
||||||
|
<Border Background="{Binding BackgroundBrush}" Padding="4,1"> |
||||||
|
<Grid ColumnDefinitions="Auto,7,*" |
||||||
|
VerticalAlignment="Center"> |
||||||
|
<Image Grid.Column="0" Source="{Binding Icon}" |
||||||
|
Width="16" Height="16" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Text}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</Grid> |
||||||
|
</Border> |
||||||
|
</DataTemplate> |
||||||
|
</DataGridHierarchicalColumn.CellTemplate> |
||||||
|
</DataGridHierarchicalColumn> |
||||||
|
</DataGrid.Columns> |
||||||
|
</DataGrid> |
||||||
|
</DockPanel> |
||||||
|
</UserControl> |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue