");
+
+ html.Should().StartWith("");
+ html.Should().Contain("Deps of <A&B>");
+ html.Should().Contain("");
+ html.Should().Contain("graph LR");
+ html.Should().Contain("n0 -->", "the diagram text is HTML-encoded inside the page");
+ html.Should().NotContain("");
+ html.Should().Contain($"");
+ html.Should().Contain("mermaid.initialize(");
+ }
+}
diff --git a/ILSpy.Tests/Dependencies/DependencyUiTests.cs b/ILSpy.Tests/Dependencies/DependencyUiTests.cs
new file mode 100644
index 00000000000..c80e0a25445
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/DependencyUiTests.cs
@@ -0,0 +1,236 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.Analyzers.TreeNodes;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Dependencies;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class DependencyUiTests
+{
+ static async Task<(MainWindowViewModel Vm, string LibraryName, string ConsumerName)> OpenPairAsync(Avalonia.Controls.Window? _ = null)
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var (libraryPath, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Ui");
+ await vm.OpenAssemblyAsync(libraryPath);
+ await vm.OpenAssemblyAsync(consumerPath);
+ return (vm, libraryName, consumerName);
+ }
+
+ static TextViewContext Select(SharpTreeNode node) => new() { SelectedTreeNodes = [node] };
+
+ [AvaloniaTest]
+ public async Task Assembly_Node_Has_A_Referenced_By_Folder_Listing_Its_Consumers()
+ {
+ var (vm, libraryName, consumerName) = await OpenPairAsync();
+ var library = vm.AssemblyTreeModel.FindNode(libraryName);
+
+ var folder = library.GetChild();
+ folder.Text.Should().Be(Resources.ReferencedBy);
+ var child = folder.GetChild();
+ child.Assembly.ShortName.Should().Be(consumerName);
+ child.IsVersionMismatch.Should().BeFalse();
+ ((string)child.Text).Should().StartWith(consumerName);
+
+ // The consumer itself has no consumers: its folder is empty.
+ var consumer = vm.AssemblyTreeModel.FindNode(consumerName);
+ var consumerFolder = consumer.GetChild();
+ consumerFolder.EnsureLazyChildren();
+ consumerFolder.Children.Should().BeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Analyze_Accepts_An_Assembly_And_Shows_The_Module_Analyzers()
+ {
+ var (vm, libraryName, _) = await OpenPairAsync();
+ var library = vm.AssemblyTreeModel.FindNode(libraryName);
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.Analyze));
+
+ entry.IsVisible(Select(library)).Should().BeTrue();
+ entry.IsEnabled(Select(library)).Should().BeTrue();
+ entry.Execute(Select(library));
+
+ var analyzerVm = AppComposition.Current.GetExport();
+ var moduleRow = analyzerVm.Root.Children.OfType().Single();
+ moduleRow.Module.MetadataFile.Should().BeSameAs(library.LoadedAssembly.GetMetadataFileOrNull());
+ moduleRow.EnsureLazyChildren();
+ moduleRow.Children.OfType().Select(r => r.AnalyzerHeader)
+ .Should().Contain([ModuleAnalyzerHeaders.ReferencedBy, ModuleAnalyzerHeaders.DependentCode]);
+
+ entry.Execute(Select(library));
+ analyzerVm.Root.Children.OfType().Should().ContainSingle("re-analyzing reuses the row");
+ }
+
+ [AvaloniaTest]
+ public async Task Referenced_By_Context_Entry_Lists_The_Consumer_In_The_Analyzer_Pane()
+ {
+ var (vm, libraryName, consumerName) = await OpenPairAsync();
+ var library = vm.AssemblyTreeModel.FindNode(libraryName);
+ var registry = AppComposition.Current.GetExport();
+ registry.Entries.Single(e => e.Metadata.Header == nameof(Resources.ReferencedBy)).Metadata.Category
+ .Should().Be("Navigation");
+ var entry = registry.GetEntry(nameof(Resources.ReferencedBy));
+
+ entry.IsVisible(Select(library)).Should().BeTrue();
+ entry.Execute(Select(library));
+
+ var analyzerVm = AppComposition.Current.GetExport();
+ var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single();
+ row.AnalyzerHeader.Should().Be(ModuleAnalyzerHeaders.ReferencedBy);
+ await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(),
+ description: "Referenced By results");
+ row.Children.OfType().Single().Module.AssemblyName.Should().Be(consumerName);
+ }
+
+ [AvaloniaTest]
+ public async Task Dependent_Code_Context_Entry_Lists_The_Using_Members()
+ {
+ var (vm, libraryName, consumerName) = await OpenPairAsync();
+ var library = vm.AssemblyTreeModel.FindNode(libraryName);
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.DependentCode));
+
+ entry.IsVisible(Select(library)).Should().BeTrue();
+ entry.Execute(Select(library));
+
+ var analyzerVm = AppComposition.Current.GetExport();
+ var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single();
+ row.AnalyzerHeader.Should().Be(ModuleAnalyzerHeaders.DependentCode);
+ await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(),
+ description: "Dependent Code results");
+ var members = row.Children.OfType().Select(n => n.Member?.FullName).ToList();
+ members.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}");
+ members.Should().NotContain($"{consumerName}.{DependencyFixtures.IndependentType}.{DependencyFixtures.IndependentMethod}");
+ }
+
+ [AvaloniaTest]
+ public async Task Dependency_Diagram_Opens_A_Tab_And_A_Node_Click_Selects_The_Assembly()
+ {
+ var (window, _) = await TestHarness.BootAsync();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ var (libraryPath, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Ui");
+ await vm.OpenAssemblyAsync(libraryPath);
+ await vm.OpenAssemblyAsync(consumerPath);
+ var consumer = vm.AssemblyTreeModel.SelectNode(consumerName);
+
+ var registry = AppComposition.Current.GetExport();
+ registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.AssemblyDependencyDiagram)).Metadata.ParentMenuID
+ .Should().Be(nameof(Resources._Navigate));
+ registry.GetCommand(nameof(Resources.AssemblyDependencyDiagram)).Execute(null);
+
+ var dockWorkspace = AppComposition.Current.GetExport();
+ ContentTabPage? tab = null;
+ await Waiters.WaitForAsync(() => {
+ tab = dockWorkspace.Documents?.VisibleDockables?.OfType()
+ .FirstOrDefault(t => t.Content is DependencyGraphViewModel);
+ return tab != null;
+ }, description: "dependency diagram tab");
+ var model = (DependencyGraphViewModel)tab!.Content!;
+ model.Graph.Nodes[0].Name.Should().Be(consumerName);
+ var libraryIndex = model.Graph.Nodes.Single(n => n.Name == libraryName).Index;
+
+ var view = await window.WaitForComponent();
+ await Waiters.WaitForAsync(() => view.GraphControl.Bounds.Width > 0 && view.GraphControl.Layout != null,
+ description: "graph control laid out");
+ window.Capture("dependency-diagram");
+
+ var center = view.GraphControl.GetNodeCenter(libraryIndex);
+ view.GraphControl.HitTestNode(center).Should().Be(libraryIndex);
+ var point = view.GraphControl.TranslatePoint(center, window)!.Value;
+ window.MouseDown(point, MouseButton.Left);
+ window.MouseUp(point, MouseButton.Left);
+
+ await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode { LoadedAssembly.ShortName: var n } && n == libraryName,
+ description: "library selected in the tree after clicking its node");
+ ReferenceEquals(consumer, vm.AssemblyTreeModel.SelectedItem).Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public async Task Mermaid_Export_Writes_An_Html_Page_With_The_Graph()
+ {
+ var (vm, libraryName, consumerName) = await OpenPairAsync();
+ vm.AssemblyTreeModel.SelectNode(consumerName);
+ var path = Path.Combine(DependencyFixtures.NewDirectory(), "deps.html");
+
+ AppComposition.Current.GetExport()
+ .GetCommand(nameof(Resources.ExportDependencyDiagramMermaid)).Execute(path);
+
+ var dockWorkspace = AppComposition.Current.GetExport();
+ await Waiters.WaitForAsync(() => dockWorkspace.Documents?.VisibleDockables?.OfType()
+ .Any(t => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.ExportDependencyDiagramMermaid) == true,
+ description: "export report tab offering to open the page");
+ File.Exists(path).Should().BeTrue();
+ var html = await File.ReadAllTextAsync(path);
+ html.Should().Contain("graph LR");
+ html.Should().Contain(consumerName);
+ html.Should().Contain(libraryName);
+ }
+
+ [AvaloniaTest]
+ public async Task Unresolved_References_Report_Lists_The_Missing_Reference_With_Its_Log()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Ui");
+ var orphan = await vm.OpenAssemblyAsync(consumerPath);
+ vm.AssemblyTreeModel.SelectNode(orphan.ShortName);
+
+ var registry = AppComposition.Current.GetExport();
+ registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.UnresolvedReferencesReport)).Metadata.ParentMenuID
+ .Should().Be(nameof(Resources._Navigate));
+ registry.GetCommand(nameof(Resources.UnresolvedReferencesReport)).Execute(null);
+
+ var dockWorkspace = AppComposition.Current.GetExport();
+ string? text = null;
+ await Waiters.WaitForAsync(() => {
+ var content = dockWorkspace.Documents?.VisibleDockables?.OfType()
+ .Select(t => t.Content).OfType()
+ .FirstOrDefault(c => c.Title == Resources.UnresolvedReferencesReport);
+ text = content?.Text;
+ return text?.Contains(missingName, StringComparison.Ordinal) == true;
+ }, description: "unresolved references report text");
+ text.Should().Contain(orphan.ShortName);
+ text.Should().Contain("Could not find reference");
+ }
+}
diff --git a/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs b/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs
new file mode 100644
index 00000000000..13b99bfbcf9
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Linq;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class LayeredLayoutTests
+{
+ [Test]
+ public void Longest_Path_Layering_Places_Each_Node_After_Its_Furthest_Predecessor()
+ {
+ // 0 -> 1 -> 2 plus the shortcut 0 -> 2: node 2 must land in layer 2, not 1.
+ var result = LayeredLayout.Compute(3, [(0, 1), (1, 2), (0, 2)]);
+
+ result.Layers.Should().Equal(0, 1, 2);
+ result.LayerCount.Should().Be(3);
+ }
+
+ [Test]
+ public void Every_Acyclic_Edge_Points_To_A_Later_Layer_And_Long_Edges_Are_Routed_Through_Each_Layer()
+ {
+ var edges = new (int, int)[] { (0, 1), (1, 2), (2, 3), (0, 3), (4, 3) };
+ var result = LayeredLayout.Compute(5, edges);
+
+ foreach (var edge in result.Edges)
+ {
+ edge.IsReversed.Should().BeFalse();
+ result.Layers[edge.To].Should().BeGreaterThan(result.Layers[edge.From]);
+ var source = result.Nodes[edge.From];
+ var target = result.Nodes[edge.To];
+ edge.Points[0].Should().Be(new LayoutPoint(source.Right, source.CenterY), "an edge leaves the right side of its source");
+ edge.Points[^1].Should().Be(new LayoutPoint(target.X, target.CenterY), "an edge enters the left side of its target");
+ int span = result.Layers[edge.To] - result.Layers[edge.From];
+ edge.Points.Count.Should().Be(2 + 2 * (span - 1), "each skipped layer adds an entry and an exit point");
+ }
+ }
+
+ [Test]
+ public void Cycles_Are_Broken_By_Reversing_A_Back_Edge()
+ {
+ var result = LayeredLayout.Compute(3, [(0, 1), (1, 2), (2, 0)]);
+
+ result.Edges.Should().HaveCount(3);
+ result.Edges.Count(e => e.IsReversed).Should().Be(1);
+ var back = result.Edges.Single(e => e.IsReversed);
+ back.From.Should().Be(2);
+ back.To.Should().Be(0);
+ result.Layers[back.From].Should().BeGreaterThan(result.Layers[back.To]);
+ // The polyline still runs from the edge's source to its target.
+ var source = result.Nodes[back.From];
+ var target = result.Nodes[back.To];
+ back.Points[0].Should().Be(new LayoutPoint(source.X, source.CenterY));
+ back.Points[^1].Should().Be(new LayoutPoint(target.Right, target.CenterY));
+ }
+
+ [Test]
+ public void Self_Loops_And_Duplicate_Edges_Are_Dropped()
+ {
+ var result = LayeredLayout.Compute(2, [(0, 0), (0, 1), (0, 1)]);
+
+ result.Edges.Should().ContainSingle().Which.Should().Match(e => e.From == 0 && e.To == 1);
+ }
+
+ [Test]
+ public void Barycenter_Ordering_Removes_An_Avoidable_Crossing()
+ {
+ // Index order puts 2 above 3 in the second layer, so 0->3 and 1->2 would cross.
+ var result = LayeredLayout.Compute(4, [(0, 3), (1, 2)]);
+
+ result.CrossingCount.Should().Be(0);
+ result.Nodes[3].Y.Should().BeLessThan(result.Nodes[2].Y, "the target of the upper source moves up");
+ }
+
+ [Test]
+ public void Nodes_Do_Not_Overlap_And_Layers_Advance_Left_To_Right()
+ {
+ var edges = Enumerable.Range(1, 8).Select(i => (0, i)).Concat(Enumerable.Range(1, 7).Select(i => (i, 9))).ToArray();
+ var widths = Enumerable.Range(0, 10).Select(i => 60.0 + 10 * i).ToArray();
+ var result = LayeredLayout.Compute(10, edges, widths);
+
+ for (int i = 0; i < 10; i++)
+ {
+ result.Nodes[i].Width.Should().Be(widths[i]);
+ for (int j = i + 1; j < 10; j++)
+ {
+ var a = result.Nodes[i];
+ var b = result.Nodes[j];
+ bool disjoint = a.Right <= b.X || b.Right <= a.X || a.Bottom <= b.Y || b.Bottom <= a.Y;
+ disjoint.Should().BeTrue($"nodes {i} and {j} must not overlap");
+ if (result.Layers[i] < result.Layers[j])
+ a.Right.Should().BeLessThan(b.X);
+ }
+ }
+ result.Nodes.Max(n => n.Right).Should().BeLessThanOrEqualTo(result.Width);
+ result.Nodes.Max(n => n.Bottom).Should().BeLessThanOrEqualTo(result.Height);
+ }
+
+ [Test]
+ public void Layout_Is_Deterministic()
+ {
+ var edges = new (int, int)[] { (0, 4), (1, 3), (2, 5), (3, 6), (4, 6), (5, 3), (6, 1), (0, 6), (2, 4) };
+ var first = LayeredLayout.Compute(7, edges);
+ var second = LayeredLayout.Compute(7, edges);
+
+ second.Nodes.Should().Equal(first.Nodes);
+ second.Layers.Should().Equal(first.Layers);
+ second.Edges.Select(e => string.Join(";", e.Points)).Should().Equal(first.Edges.Select(e => string.Join(";", e.Points)));
+ }
+
+ [Test]
+ public void HitTest_Finds_The_Node_Under_A_Point()
+ {
+ var result = LayeredLayout.Compute(2, [(0, 1)]);
+ var target = result.Nodes[1];
+
+ result.HitTest(target.X + 1, target.CenterY).Should().Be(1);
+ result.HitTest(-5, -5).Should().Be(-1);
+ }
+
+ [Test]
+ public void Empty_Graph_Has_No_Extent()
+ {
+ var result = LayeredLayout.Compute(0, Array.Empty<(int, int)>());
+
+ result.Nodes.Should().BeEmpty();
+ result.Width.Should().Be(0);
+ result.Height.Should().Be(0);
+ }
+}
diff --git a/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs
new file mode 100644
index 00000000000..4b8c7d51b34
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.Analyzers.Builtin;
+
+using ICSharpCode.ILSpy.Languages;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class ModuleAnalyzersTests
+{
+ AssemblyList list = null!;
+ LoadedAssembly library = null!;
+ LoadedAssembly consumer = null!;
+ string consumerName = null!;
+
+ [OneTimeSetUp]
+ public async Task Setup()
+ {
+ var (libraryPath, consumerPath, _, name) = DependencyFixtures.EmitPair("Analyze");
+ consumerName = name;
+ list = new AssemblyList();
+ library = list.OpenAssembly(libraryPath);
+ consumer = list.OpenAssembly(consumerPath);
+ await library.GetLoadResultAsync();
+ await consumer.GetLoadResultAsync();
+ // An assembly that has nothing to do with the library must not contribute results.
+ await list.OpenAssembly(FixtureAssembly.Emit(DependencyFixtures.UniqueName("Bystander"))).GetLoadResultAsync();
+ }
+
+ AnalyzerContext CreateContext(CancellationToken ct = default)
+ => new() { AssemblyList = list, Language = new CSharpLanguage(), CancellationToken = ct };
+
+ IModule LibraryModule()
+ => new DecompilerTypeSystem(library.GetMetadataFileOrNull()!, library.GetAssemblyResolver()).MainModule;
+
+ [Test]
+ public void Module_Analyzers_Show_Only_For_Assemblies()
+ {
+ var module = LibraryModule();
+ var type = module.TopLevelTypeDefinitions.First(t => t.Name == "Api");
+
+ new ModuleDependentCodeAnalyzer().Show(module).Should().BeTrue();
+ new ModuleDependentCodeAnalyzer().Show(type).Should().BeFalse();
+ new ModuleDependentCodeAnalyzer().Show(null).Should().BeFalse();
+ new ModuleReferencedByAnalyzer().Show(module).Should().BeTrue();
+ new ModuleReferencedByAnalyzer().Show(type).Should().BeFalse();
+ }
+
+ [Test]
+ public void Dependent_Code_Lists_Body_Signature_And_Base_Type_Users_Only()
+ {
+ var results = new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList();
+
+ var names = results.OfType().Select(e => e.FullName).ToList();
+ names.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}");
+ names.Should().Contain($"{consumerName}.{DependencyFixtures.SignatureUserType}.{DependencyFixtures.SignatureUserMethod}");
+ names.Should().Contain($"{consumerName}.{DependencyFixtures.DerivedType}");
+ names.Should().NotContain(n => n.Contains(DependencyFixtures.IndependentType, StringComparison.Ordinal));
+ results.OfType().Should().OnlyContain(e => e.ParentModule!.MetadataFile == consumer.GetMetadataFileOrNull(),
+ "only assemblies that reference the library contribute");
+ names.Should().OnlyHaveUniqueItems();
+ }
+
+ [Test]
+ public void Dependent_Code_Honours_Cancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var act = () => new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext(cts.Token)).ToList();
+
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Referenced_By_Lists_The_Consumer_Module()
+ {
+ var results = new ModuleReferencedByAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList();
+
+ results.Should().ContainSingle().Which.Should().BeAssignableTo()
+ .Which.MetadataFile.Should().BeSameAs(consumer.GetMetadataFileOrNull());
+ }
+}
diff --git a/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs
new file mode 100644
index 00000000000..9d124e19bdd
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class ReferencedByFinderTests
+{
+ static async Task OpenAsync(AssemblyList list, string path)
+ {
+ var loaded = list.OpenAssembly(path);
+ await loaded.GetLoadResultAsync();
+ return loaded;
+ }
+
+ [Test]
+ public async Task Finds_The_Consumer_Of_A_Library_And_Nothing_Else()
+ {
+ var (libraryPath, consumerPath, _, _) = DependencyFixtures.EmitPair("RefBy");
+ var other = FixtureAssembly.Emit(DependencyFixtures.UniqueName("Unrelated"));
+ var list = new AssemblyList();
+ var library = await OpenAsync(list, libraryPath);
+ var consumer = await OpenAsync(list, consumerPath);
+ await OpenAsync(list, other);
+
+ var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!);
+
+ result.Should().ContainSingle();
+ result[0].Assembly.Should().BeSameAs(consumer);
+ result[0].Reference.Name.Should().Be(library.ShortName);
+ result[0].IsVersionMismatch.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task A_Different_Referenced_Version_Still_Matches_And_Is_Reported()
+ {
+ var dir = DependencyFixtures.NewDirectory();
+ var libraryName = DependencyFixtures.UniqueName("RefByLib");
+ var libraryPath = DependencyFixtures.EmitLibrary(dir, libraryName, new Version(1, 0, 0, 0));
+ var consumerPath = DependencyFixtures.EmitConsumer(dir, DependencyFixtures.UniqueName("RefByApp"), libraryPath);
+ DependencyFixtures.EmitLibrary(dir, libraryName, new Version(3, 1, 0, 0));
+ var list = new AssemblyList();
+ var library = await OpenAsync(list, libraryPath);
+ await OpenAsync(list, consumerPath);
+
+ var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!);
+
+ result.Should().ContainSingle().Which.IsVersionMismatch.Should().BeTrue();
+ }
+
+ [Test]
+ public void Matching_Requires_The_Same_Public_Key_Token()
+ {
+ // System.Runtime is strong-named; a reference with the right name but no token is a different assembly.
+ var runtimePath = Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll");
+ using var runtime = new PEFile(runtimePath);
+ string token = AssemblyReferenceMatcher.GetPublicKeyToken(runtime.Metadata)!;
+
+ token.Should().NotBe(AssemblyReferenceMatcher.NullToken);
+ AssemblyReferenceMatcher.Matches(
+ AssemblyNameReference.Parse($"System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}"), runtime)
+ .Should().BeTrue("name and token match; the version is not part of the identity");
+ AssemblyReferenceMatcher.Matches(
+ AssemblyNameReference.Parse("System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"), runtime)
+ .Should().BeFalse();
+ AssemblyReferenceMatcher.Matches(
+ AssemblyNameReference.Parse($"System.Runtime2, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}"), runtime)
+ .Should().BeFalse();
+ }
+}
diff --git a/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs
new file mode 100644
index 00000000000..9246068a5c2
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 AwesomeAssertions;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class UnresolvedReferencesReportTests
+{
+ [Test]
+ public async Task Missing_Reference_Is_Reported_Under_Its_Referencing_Assembly_With_The_Probe_Log()
+ {
+ var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Report");
+ var (libraryPath, resolvableConsumerPath, libraryName, _) = DependencyFixtures.EmitPair("Report");
+ var list = new AssemblyList();
+ var orphan = list.OpenAssembly(consumerPath);
+ var healthy = list.OpenAssembly(resolvableConsumerPath);
+ await orphan.GetLoadResultAsync();
+ await healthy.GetLoadResultAsync();
+
+ var groups = UnresolvedReferencesReport.Collect([orphan, healthy]);
+
+ var group = groups.Single(g => g.Assembly == orphan);
+ var missing = group.References.Single(r => r.Reference.Name == missingName);
+ missing.Messages.Should().Contain(m => m.Kind == MessageKind.Error && m.Message.Contains(missingName),
+ "the resolver's probe log explains why the reference failed");
+ groups.SelectMany(g => g.References).Should().NotContain(r => r.Reference.Name == libraryName,
+ "a reference that resolves is not reported");
+ }
+
+ [Test]
+ public async Task Fully_Resolved_Assemblies_Produce_No_Group()
+ {
+ var (libraryPath, _, _, _) = DependencyFixtures.EmitPair("Report");
+ var list = new AssemblyList();
+ var library = list.OpenAssembly(libraryPath);
+ await library.GetLoadResultAsync();
+
+ // The fixture library only references the core library, which always resolves.
+ UnresolvedReferencesReport.Collect([library]).Should().BeEmpty();
+ }
+}
diff --git a/ILSpy.Tests/Docking/DockWorkspaceTests.cs b/ILSpy.Tests/Docking/DockWorkspaceTests.cs
index c97c0e14d3e..16a57687374 100644
--- a/ILSpy.Tests/Docking/DockWorkspaceTests.cs
+++ b/ILSpy.Tests/Docking/DockWorkspaceTests.cs
@@ -43,11 +43,11 @@ public void DockWorkspace_resolves_and_exposes_root_layout_with_tool_panes()
workspace.Layout.Should().NotBeNull("ILSpyDockFactory.CreateLayout() wires the root dock in the ctor.");
workspace.Factory.Should().NotBeNull();
#if DEBUG
- workspace.ToolPaneMenuItems.Should().HaveCount(5,
- "AssemblyTree, Search, Analyzers, Bookmarks, and the Debug Steps pane (Debug-only) are wired at this point.");
+ workspace.ToolPaneMenuItems.Should().HaveCount(7,
+ "AssemblyTree, Search, Analyzers, Bookmarks, Type Hierarchy, File Structure, and the Debug Steps pane (Debug-only) are wired at this point.");
#else
- workspace.ToolPaneMenuItems.Should().HaveCount(4,
- "AssemblyTree, Search, Analyzers, and Bookmarks are the tool panes wired at this point.");
+ workspace.ToolPaneMenuItems.Should().HaveCount(6,
+ "AssemblyTree, Search, Analyzers, Bookmarks, Type Hierarchy, and File Structure are the tool panes wired at this point.");
#endif
}
}
diff --git a/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs
new file mode 100644
index 00000000000..de0f3748f7a
--- /dev/null
+++ b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 System.Threading.Tasks;
+using System.Xml.Linq;
+
+using Avalonia.Headless.NUnit;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Options;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.TextView;
+
+///
+/// Highlight usages at caret (ReSharper-style): while the caret rests on a symbol, every
+/// occurrence of that symbol in the document is marked. Independent of the click highlight
+/// (), which keeps its own marks.
+///
+[TestFixture]
+public class CaretUsageHighlightTests
+{
+ static async Task<(DecompilerTextView View, DecompilerTabPageModel Tab, DisplaySettings Settings)> SetupAsync(bool enabled = true)
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ var settings = AppComposition.Current.GetExport().DisplaySettings;
+ settings.HighlightUsagesAtCaret = enabled;
+ await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "ILSpy.Tests",
+ "ICSharpCode.ILSpy.Tests.TextView",
+ "ICSharpCode.ILSpy.Tests.TextView.MemberHighlightSample");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var view = window.GetVisualDescendants().OfType().First();
+ return (view, tab, settings);
+ }
+
+ static List MemberSegments(DecompilerTabPageModel tab, string name)
+ => tab.References!
+ .Where(r => r.Kind == ReferenceMode.Link && r.Reference is IMember m && m.Name == name)
+ .ToList();
+
+ [Test]
+ public void Setting_Defaults_To_On_And_Round_Trips()
+ {
+ new DisplaySettings().HighlightUsagesAtCaret.Should().BeTrue();
+
+ var loaded = new DisplaySettings();
+ loaded.LoadFromXml(new XElement("DisplaySettings"));
+ loaded.HighlightUsagesAtCaret.Should().BeTrue("a missing attribute keeps the default");
+
+ var off = new DisplaySettings { HighlightUsagesAtCaret = false };
+ var reloaded = new DisplaySettings();
+ reloaded.LoadFromXml(off.SaveToXml());
+ reloaded.HighlightUsagesAtCaret.Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public async Task Caret_On_A_Member_Use_Marks_Every_Occurrence()
+ {
+ var (view, tab, _) = await SetupAsync();
+ var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field));
+ var use = fieldSegments.First(r => !r.IsDefinition);
+
+ view.Editor.TextArea.Caret.Offset = use.StartOffset + 1;
+
+ view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
+ fieldSegments.Select(s => s.StartOffset),
+ "the definition and every use of the field under the caret are marked");
+ view.LocalReferenceMarks.Should().BeEmpty("the caret highlight must not touch the click-highlight marks");
+ }
+
+ [AvaloniaTest]
+ public async Task Caret_At_The_End_Of_An_Identifier_Still_Counts_As_On_It()
+ {
+ var (view, tab, _) = await SetupAsync();
+ var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field));
+ var use = fieldSegments.First(r => !r.IsDefinition && r.Length > 1);
+
+ view.Editor.TextArea.Caret.Offset = use.EndOffset;
+
+ view.CaretUsageMarks.Should().NotBeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Caret_On_A_Parameter_Marks_Its_Local_Occurrences()
+ {
+ var (view, tab, _) = await SetupAsync();
+ var itemSegments = tab.References!
+ .Where(r => r.Kind == ReferenceMode.LocalHighlight && tab.Text.Substring(r.StartOffset, r.Length) == "item")
+ .ToList();
+ itemSegments.Should().HaveCountGreaterThanOrEqualTo(2, "the parameter is declared and used once");
+
+ view.Editor.TextArea.Caret.Offset = itemSegments[0].StartOffset;
+
+ view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
+ itemSegments.Select(s => s.StartOffset));
+ }
+
+ [AvaloniaTest]
+ public async Task Moving_The_Caret_Off_A_Symbol_Clears_The_Marks()
+ {
+ var (view, tab, _) = await SetupAsync();
+ var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition);
+ view.Editor.TextArea.Caret.Offset = use.StartOffset;
+ view.CaretUsageMarks.Should().NotBeEmpty();
+
+ // Offset 0 is the start of the leading comment/using block, which carries no reference.
+ tab.References!.FindSegmentsContaining(0).Should().BeEmpty();
+ view.Editor.TextArea.Caret.Offset = 0;
+
+ view.CaretUsageMarks.Should().BeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Disabled_Setting_Marks_Nothing_And_Turning_It_Off_Clears_Marks()
+ {
+ var (view, tab, settings) = await SetupAsync(enabled: false);
+ var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition);
+
+ view.Editor.TextArea.Caret.Offset = use.StartOffset;
+ view.CaretUsageMarks.Should().BeEmpty("the option is off");
+
+ settings.HighlightUsagesAtCaret = true;
+ view.CaretUsageMarks.Should().NotBeEmpty("turning the option on highlights the symbol already under the caret");
+
+ settings.HighlightUsagesAtCaret = false;
+ view.CaretUsageMarks.Should().BeEmpty("turning the option off clears the marks");
+ }
+}
diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs
index b770a8e9fd8..bca65bb61e9 100644
--- a/ILSpy.Tests/MainWindow/MainMenuTests.cs
+++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs
@@ -36,7 +36,7 @@
namespace ICSharpCode.ILSpy.Tests;
-// MainMenu's top-level structure (File / View / Window with mnemonic underscores) is the
+// MainMenu's top-level structure (File / View / Navigate / Window with mnemonic underscores) is the
// scaffolding every later commit hangs items onto via MEF. If a future commit accidentally
// drops one of these top-levels or shuffles the order, the [ExportMainMenuCommand] entries
// that target them by header would silently land in the wrong menu.
@@ -44,7 +44,7 @@ namespace ICSharpCode.ILSpy.Tests;
public class MainMenuTests
{
[AvaloniaTest]
- public void MainMenu_top_level_items_are_File_View_Window_in_order()
+ public void MainMenu_top_level_items_are_File_View_Navigate_Window_in_order()
{
var window = AppComposition.Current.GetExport();
window.Show();
@@ -58,7 +58,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order()
// PromoteHelpToMacAppMenu relocates the Help items into the application menu
// (macOS convention: About lives under the bold app-named menu), so _Help is
// not a window-menu top-level there.
- headers.Should().Equal("_File", "_View", "_Window");
+ headers.Should().Equal("_File", "_View", "_Navigate", "_Window");
var appMenu = NativeMenu.GetMenu(Application.Current!);
appMenu.Should().NotBeNull("App.axaml declares the NativeMenu the Help items move into");
@@ -67,7 +67,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order()
}
else
{
- headers.Should().Equal("_File", "_View", "_Window", "_Help");
+ headers.Should().Equal("_File", "_View", "_Navigate", "_Window", "_Help");
}
}
@@ -156,7 +156,7 @@ public void Closing_An_Earlier_Window_Leaves_A_Later_Window_Help_Items_In_Place(
// The app menu is declared on Application and outlives every test, so a test that promotes
// placeholder items into it has to take them back out; otherwise a later test reading it
- // (see MainMenu_top_level_items_are_File_View_Window_in_order) sees this test's leftovers.
+ // (see MainMenu_top_level_items_are_File_View_Navigate_Window_in_order) sees this test's leftovers.
static void RestoreAppMenu(NativeMenu appMenu, List promoted)
{
foreach (var item in promoted)
diff --git a/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
index 162215f5e70..0c702b14d14 100644
--- a/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
+++ b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
@@ -29,7 +29,9 @@
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.FileStructure;
using ICSharpCode.ILSpy.Search;
+using ICSharpCode.ILSpy.TypeHierarchy;
using NUnit.Framework;
@@ -87,6 +89,20 @@ public void DockFactory_Builds_Layout_Zones_Driven_By_The_Registry()
allDockables.OfType().Should().ContainSingle();
allDockables.OfType().Should().BeEmpty("Search is hidden until invoked");
allDockables.OfType().Should().BeEmpty("Analyzer is hidden until invoked");
+ allDockables.OfType().Should().BeEmpty("Type Hierarchy is hidden until invoked");
+ allDockables.OfType().Should().BeEmpty("File Structure is hidden until invoked");
+ }
+
+ [AvaloniaTest]
+ public void Registry_Lists_The_Structure_Panes_In_Their_Dock_Zones()
+ {
+ var registry = AppComposition.Current.GetExport();
+ var byType = registry.Panes.ToDictionary(p => p.Pane.GetType(), p => p.Metadata);
+
+ byType[typeof(TypeHierarchyViewModel)].Alignment.Should().Be(ToolPaneAlignment.Bottom);
+ byType[typeof(TypeHierarchyViewModel)].IsVisibleByDefault.Should().BeFalse();
+ byType[typeof(FileStructureViewModel)].Alignment.Should().Be(ToolPaneAlignment.Right);
+ byType[typeof(FileStructureViewModel)].IsVisibleByDefault.Should().BeFalse();
}
static System.Collections.Generic.IEnumerable FlattenDockables(Dock.Model.Core.IDockable root)
diff --git a/ILSpy.Tests/Navigation/FileStructureTests.cs b/ILSpy.Tests/Navigation/FileStructureTests.cs
new file mode 100644
index 00000000000..fc191eebfe1
--- /dev/null
+++ b/ILSpy.Tests/Navigation/FileStructureTests.cs
@@ -0,0 +1,171 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.FileStructure;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class FileStructureTests
+{
+ internal static async Task<(MainWindowViewModel Vm, DecompilerTabPageModel Tab)> DecompileFixtureTypeAsync(string typeName)
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, typeName));
+ var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ tab.Text.Should().Contain($"class {typeName}");
+ return (vm, tab);
+ }
+
+ static FileStructureViewModel ShowPane()
+ {
+ AppComposition.Current.GetExport()
+ .GetCommand(nameof(Resources.FileStructure)).Execute(null);
+ return AppComposition.Current.GetExport();
+ }
+
+ [AvaloniaTest]
+ public async Task Pane_Lists_Types_And_Members_Of_The_Active_Document_In_Document_Order()
+ {
+ // Arrange
+ var (vm, tab) = await DecompileFixtureTypeAsync("Circle");
+
+ // Act
+ var pane = ShowPane();
+
+ // Assert -- the pane is visible and lists the type first, then its members, each
+ // pointing at the place its name is written.
+ vm.DockWorkspace.ToolPaneMenuItems.Single(i => i.Title == Resources.FileStructure)
+ .IsPaneVisible.Should().BeTrue();
+ var entries = pane.Entries.ToList();
+ entries.Select(e => e.Name).Should().ContainInOrder("Circle", "radius", "Area", "Diameter");
+ entries.Select(e => e.Offset).Should().BeInAscendingOrder();
+ entries.Should().OnlyContain(e => e.Icon != null);
+ foreach (var entry in entries)
+ tab.Text.AsSpan(entry.Offset).StartsWith(entry.Name.AsSpan(), StringComparison.Ordinal)
+ .Should().BeTrue($"'{entry.Name}' must point at its definition");
+ var type = entries.Single(e => e.Name == "Circle");
+ type.Entity.Should().BeAssignableTo();
+ entries.Where(e => e != type).Should().OnlyContain(e => e.Depth > type.Depth);
+ }
+
+ [AvaloniaTest]
+ public async Task Activating_An_Entry_Moves_The_Caret_To_Its_Definition()
+ {
+ var (_, tab) = await DecompileFixtureTypeAsync("Circle");
+ var pane = ShowPane();
+ var diameter = pane.Entries.Single(e => e.Name == "Diameter");
+
+ // Act
+ pane.Activate(diameter).Should().BeTrue();
+
+ // Assert
+ tab.CaptureViewState.Should().NotBeNull("the text view must be attached to the active tab");
+ tab.CaptureViewState!().CaretOffset.Should().Be(diameter.Offset);
+ }
+
+ [AvaloniaTest]
+ public async Task Pane_View_Lists_The_Entries_And_Enter_Moves_The_Caret()
+ {
+ var (_, tab) = await DecompileFixtureTypeAsync("Circle");
+ var pane = ShowPane();
+ var window = AppComposition.Current.GetExport();
+ var view = await window.WaitForComponent();
+ var list = view.FindControl("EntryList")!;
+ list.ItemsSource.Should().BeSameAs(pane.Entries);
+ var area = pane.Entries.Single(e => e.Name == "Area");
+
+ // Act -- select Area and press Enter in the list.
+ pane.SelectedEntry = area;
+ Dispatcher.UIThread.RunJobs();
+ list.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter, Source = list });
+
+ // Assert
+ tab.CaptureViewState!().CaretOffset.Should().Be(area.Offset);
+ }
+
+ [AvaloniaTest]
+ public async Task Pane_Follows_The_Active_Document_Content()
+ {
+ var (vm, _) = await DecompileFixtureTypeAsync("Circle");
+ var pane = ShowPane();
+ pane.Entries.Should().Contain(e => e.Name == "Circle");
+
+ // Act -- navigate the same tab to another type.
+ vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, "Square"));
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ // Assert
+ await Waiters.WaitForAsync(() => pane.Entries.Any(e => e.Name == "Square"),
+ description: "the file structure to show the newly decompiled type");
+ pane.Entries.Should().NotContain(e => e.Name == "Circle");
+ }
+
+ [AvaloniaTest]
+ public async Task IL_Output_Lists_Definitions_With_Their_Metadata_Names()
+ {
+ // The IL view writes unresolved metadata references; the outline still names them.
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ var language = AppComposition.Current.GetExport();
+ language.CurrentLanguage = language.Languages.Single(l => l.Name == "IL");
+ try
+ {
+ vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, "Circle"));
+ var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ tab.Text.Should().Contain(".class");
+
+ var pane = ShowPane();
+
+ pane.Entries.Select(e => e.Name).Should().ContainInOrder("Circle", "radius", "Area", "Diameter");
+ pane.Entries.Should().OnlyContain(e => e.Icon != null);
+ }
+ finally
+ {
+ language.CurrentLanguage = language.Languages.Single(l => l.Name == "C#");
+ }
+ }
+
+ [Test]
+ public void Gesture_Is_Ctrl_Alt_F()
+ {
+ var gesture = KeyGesture.Parse(FileStructureCommand.Gesture);
+ gesture.Key.Should().Be(Key.F);
+ gesture.KeyModifiers.Should().Be(KeyModifiers.Control | KeyModifiers.Alt);
+ }
+}
diff --git a/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs b/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs
new file mode 100644
index 00000000000..9862609a652
--- /dev/null
+++ b/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs
@@ -0,0 +1,177 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+/// Interface member whose rename must reach the implementations and every call site.
+public interface IRenameTarget
+{
+ int Compute(int value);
+}
+
+/// Implements with a virtual member that is overridden below.
+public class RenameTargetBase : IRenameTarget
+{
+ public virtual int Compute(int value) => value;
+}
+
+/// Overrides the virtual implementation.
+public class RenameTargetDerived : RenameTargetBase
+{
+ public override int Compute(int value) => value + 1;
+}
+
+/// Calls the member through the interface and through the derived class.
+public static class RenameTargetCaller
+{
+ public static int ViaInterface(IRenameTarget target) => target.Compute(1);
+
+ public static int ViaDerived() => new RenameTargetDerived().Compute(2);
+
+ public static int Unrelated() => 3;
+}
+
+///
+/// Read-only "Find Usages for Rename": for a symbol, everything a rename would have to touch --
+/// its declaration, related declarations (implementations / overrides / base members, type
+/// constructors) and every usage across the loaded assemblies -- without editing anything.
+///
+[TestFixture]
+public class FindUsagesForRenameTests
+{
+ static async Task<(MainWindow Window, MainWindowViewModel Vm, ITypeDefinition Base)> SetupAsync()
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ await vm.OpenAssemblyAsync(typeof(RenameTargetBase).Assembly.Location);
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "ILSpy.Tests",
+ "ICSharpCode.ILSpy.Tests.Navigation",
+ "ICSharpCode.ILSpy.Tests.Navigation.RenameTargetBase");
+ return (window, vm, typeNode.TypeDefinition);
+ }
+
+ static string Names(System.Collections.Generic.IEnumerable entities)
+ => string.Join(", ", entities.Select(e => e.DeclaringTypeDefinition?.Name + "." + e.Name));
+
+ [AvaloniaTest]
+ public async Task Virtual_Member_Collects_Base_Interface_Override_And_All_Call_Sites()
+ {
+ var (_, vm, baseType) = await SetupAsync();
+ var compute = baseType.Methods.Single(m => m.Name == nameof(RenameTargetBase.Compute));
+
+ var result = await RenameUsageFinder.FindAsync(compute, vm.AssemblyTreeModel.AssemblyList!,
+ vm.DockWorkspace.ActiveDecompilerTab?.Language ?? AppComposition.Current.GetExport().CurrentLanguage,
+ CancellationToken.None);
+
+ Names(result.Declarations).Should().Be("RenameTargetBase.Compute");
+ Names(result.RelatedDeclarations).Should().Contain("IRenameTarget.Compute")
+ .And.Contain("RenameTargetDerived.Compute");
+ var usageNames = result.Usages.Select(u => u.DeclaringTypeDefinition?.Name + "." + u.Name).ToList();
+ usageNames.Should().Contain("RenameTargetCaller.ViaInterface", "the interface call site must change too");
+ usageNames.Should().Contain("RenameTargetCaller.ViaDerived", "the call through the override must change too");
+ usageNames.Should().NotContain("RenameTargetCaller.Unrelated");
+ }
+
+ [AvaloniaTest]
+ public async Task Type_Collects_Its_Constructors_And_Usages()
+ {
+ var (_, vm, baseType) = await SetupAsync();
+ var derived = baseType.ParentModule!.TopLevelTypeDefinitions.Single(t => t.Name == nameof(RenameTargetDerived));
+
+ var result = await RenameUsageFinder.FindAsync(derived, vm.AssemblyTreeModel.AssemblyList!,
+ AppComposition.Current.GetExport().CurrentLanguage, CancellationToken.None);
+
+ result.Declarations.Should().ContainSingle().Which.Name.Should().Be(nameof(RenameTargetDerived));
+ result.RelatedDeclarations.OfType().Should().Contain(m => m.IsConstructor,
+ "a type's constructors carry its name");
+ result.Usages.Select(u => u.Name).Should().Contain(nameof(RenameTargetCaller.ViaDerived));
+ }
+
+ [AvaloniaTest]
+ public async Task Command_Opens_A_Navigable_Report_For_The_Selected_Member()
+ {
+ var (_, vm, _) = await SetupAsync();
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "ILSpy.Tests",
+ "ICSharpCode.ILSpy.Tests.Navigation",
+ "ICSharpCode.ILSpy.Tests.Navigation.RenameTargetBase");
+ typeNode.EnsureLazyChildren();
+ var methodNode = typeNode.Children.OfType().Single(m => m.MethodDefinition.Name == nameof(RenameTargetBase.Compute));
+ vm.AssemblyTreeModel.SelectNode(methodNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.FindUsagesForRename));
+ command.CanExecute(null).Should().BeTrue();
+ command.Execute(null);
+
+ DecompilerTabPageModel? report = null;
+ await Waiters.WaitForAsync(() => {
+ report = vm.DockWorkspace.Documents!.VisibleDockables!.OfType()
+ .Select(t => t.Content).OfType()
+ .FirstOrDefault(t => t.Title?.StartsWith(Resources.FindUsagesForRename, StringComparison.Ordinal) == true
+ && !t.IsDecompiling && t.Text.Contains("ViaInterface"));
+ return report != null;
+ }, null, "the Find Usages for Rename report tab");
+
+ report!.Text.Should().Contain("ILSpy.Tests", "results are grouped by assembly");
+ report.Text.Should().Contain("RenameTargetCaller", "and by declaring type");
+ var link = report.References!.FirstOrDefault(r => r.Reference is IMethod { Name: nameof(RenameTargetCaller.ViaInterface) });
+ link.Should().NotBeNull("every row links to its entity");
+
+ report.RaiseNavigateRequested(link!);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var selected = ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeOfType().Subject;
+ selected.MethodDefinition.Name.Should().Be(nameof(RenameTargetCaller.ViaInterface));
+ }
+
+ [AvaloniaTest]
+ public async Task Context_Menu_Entry_Is_In_The_Navigation_Category_And_Visible_For_Code_References()
+ {
+ var (_, _, baseType) = await SetupAsync();
+ var registry = AppComposition.Current.GetExport();
+ var export = registry.Entries.Single(e => e.Metadata.Header == nameof(Resources.FindUsagesForRename));
+ export.Metadata.Category.Should().Be("Navigation");
+
+ var compute = baseType.Methods.Single(m => m.Name == nameof(RenameTargetBase.Compute));
+ var segment = new ReferenceSegment { Reference = compute, StartOffset = 0, Length = 1 };
+ export.Value.IsVisible(new TextViewContext { Reference = segment }).Should().BeTrue();
+ export.Value.IsVisible(new TextViewContext()).Should().BeFalse();
+ }
+}
diff --git a/ILSpy.Tests/Navigation/GoToCommandTests.cs b/ILSpy.Tests/Navigation/GoToCommandTests.cs
new file mode 100644
index 00000000000..32f3ece796f
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToCommandTests.cs
@@ -0,0 +1,207 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToCommandTests
+{
+ // Decompiles the fixture's Circle type and puts the caret (with keyboard focus) on the
+ // "ShapeBase" reference in its base-type list.
+ static async Task<(MainWindow Window, MainWindowViewModel ViewModel, DecompilerTextView View)> CaretOnBaseTypeReferenceAsync()
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ await GoToFixture.OpenAsync(vm);
+ vm.AssemblyTreeModel.SelectNode(GoToFixture.TypeNode(vm, "Circle"));
+ var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var view = window.GetVisualDescendants().OfType().First(v => v.DataContext == tab);
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ Dispatcher.UIThread.RunJobs();
+
+ var segment = tab.References!.First(r => !r.IsDefinition && r.Reference is ITypeDefinition { Name: "ShapeBase" });
+ view.Editor.TextArea.Focus();
+ view.Editor.TextArea.Caret.Offset = segment.StartOffset + 1;
+ Dispatcher.UIThread.RunJobs();
+ return (window, vm, view);
+ }
+
+ static NativeMenuItem NavigateMenu(MainWindow window)
+ {
+ var menu = NativeMenu.GetMenu(window) ?? throw new InvalidOperationException("main menu not attached");
+ return menu.Items.OfType().Single(i => i.Header == Resources._Navigate);
+ }
+
+ [AvaloniaTest]
+ public async Task Navigate_Menu_Lists_The_Go_To_Commands_With_Their_Shortcuts()
+ {
+ var (window, _) = await TestHarness.BootAsync();
+ var items = NavigateMenu(window).Menu!.Items.OfType()
+ .Where(i => i is not NativeMenuItemSeparator)
+ .ToDictionary(i => i.Header!, i => i.Gesture);
+
+ items[Resources.NavigateTo].Should().Be(KeyGesture.Parse(OperatingSystem.IsMacOS() ? "Cmd+Shift+G" : "Ctrl+Shift+G"));
+ items[Resources.GoToDeclaration].Should().Be(KeyGesture.Parse("F12"));
+ items[Resources.GoToImplementation].Should().Be(KeyGesture.Parse(OperatingSystem.IsMacOS() ? "Cmd+F12" : "Ctrl+F12"));
+ items[Resources.GoToBaseSymbols].Should().Be(KeyGesture.Parse("Alt+Home"));
+ items[Resources.GoToDerivedSymbols].Should().Be(KeyGesture.Parse("Alt+End"));
+ items[Resources.LocateInAssemblyExplorer].Should().Be(KeyGesture.Parse("Shift+Alt+L"));
+ items[Resources.Analyze].Should().Be(KeyGesture.Parse("Shift+F12"));
+ }
+
+ [AvaloniaTest]
+ public async Task Main_Menu_Go_To_Declaration_Acts_On_The_Symbol_Under_The_Caret()
+ {
+ var (_, vm, _) = await CaretOnBaseTypeReferenceAsync();
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToDeclaration));
+ var navigator = AppComposition.Current.GetExport();
+
+ command.CanExecute(null).Should().BeTrue();
+ command.Execute(null);
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+ selected.Should().NotBeNull();
+ GoToFixture.Describe(selected!).Should().Be("ShapeBase");
+ }
+
+ [AvaloniaTest]
+ public async Task Main_Menu_Go_To_Derived_Acts_On_The_Selected_Tree_Node_When_The_Tree_Has_Focus()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await GoToFixture.OpenAsync(vm);
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToDerivedSymbols));
+ var navigator = AppComposition.Current.GetExport();
+ var shape = GoToFixture.TypeNode(vm, "IShape");
+ vm.AssemblyTreeModel.SelectNode(shape);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ ActiveNavigationContext.ClearLastFocusedTextView();
+
+ command.Execute(null);
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+ }
+
+ [AvaloniaTest]
+ public async Task Caret_Context_Resolves_The_Reference_At_The_Caret()
+ {
+ var (_, _, view) = await CaretOnBaseTypeReferenceAsync();
+ var context = ActiveNavigationContext.ForTextViewCaret(view);
+ context.TextView.Should().BeSameAs(view);
+ (context.Reference?.Reference as ITypeDefinition)?.Name.Should().Be("ShapeBase");
+ }
+
+ [AvaloniaTest]
+ public async Task Navigate_To_Lists_Visible_Navigation_Entries_And_Runs_The_Chosen_One()
+ {
+ var (_, vm, _) = await CaretOnBaseTypeReferenceAsync();
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.NavigateTo));
+ var navigator = AppComposition.Current.GetExport();
+
+ command.Execute(null);
+ Dispatcher.UIThread.RunJobs();
+
+ var chooser = navigator.ActiveChooser;
+ chooser.Should().NotBeNull("Navigate To opens the action chooser");
+ var texts = chooser!.VisibleChoices.Select(c => c.Text).ToList();
+ texts.Should().Contain(new[] {
+ Resources.GoToDeclaration, Resources.GoToImplementation, Resources.GoToDerivedSymbols,
+ Resources.GoToBaseSymbols, Resources.LocateInAssemblyExplorer, Resources.Decompile });
+ texts.Should().NotContain(Resources.Analyze, "only Navigation-category entries are offered");
+
+ chooser.Filter = Resources.GoToDeclaration;
+ chooser.Accept();
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+ }
+
+ [AvaloniaTest]
+ public async Task Locate_In_Assembly_Explorer_Selects_The_Caret_Symbol_Node()
+ {
+ var (_, vm, view) = await CaretOnBaseTypeReferenceAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.LocateInAssemblyExplorer));
+ var context = ActiveNavigationContext.ForTextViewCaret(view);
+
+ entry.IsVisible(context).Should().BeTrue();
+ entry.Execute(context);
+ Dispatcher.UIThread.RunJobs();
+
+ GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+ }
+
+ [AvaloniaTest]
+ public async Task Locate_In_Assembly_Explorer_Falls_Back_To_The_Document_Node()
+ {
+ var (_, vm, view) = await CaretOnBaseTypeReferenceAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.LocateInAssemblyExplorer));
+ var circle = GoToFixture.TypeNode(vm, "Circle");
+ // The document's node is no longer selected (e.g. the user browsed the tree while keeping the tab).
+ vm.AssemblyTreeModel.SelectedItems.Clear();
+ var context = new TextViewContext { TextView = view };
+
+ entry.IsVisible(context).Should().BeTrue();
+ entry.Execute(context);
+ Dispatcher.UIThread.RunJobs();
+
+ ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, circle).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Find_Usages_Menu_Command_Analyzes_The_Caret_Symbol()
+ {
+ var (_, _, _) = await CaretOnBaseTypeReferenceAsync();
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.Analyze));
+ var analyzer = AppComposition.Current.GetExport();
+
+ command.Execute(null);
+ Dispatcher.UIThread.RunJobs();
+
+ analyzer.Root.Children.OfType()
+ .Should().Contain(n => n.Member != null && n.Member.Name == "ShapeBase");
+ }
+}
diff --git a/ILSpy.Tests/Navigation/GoToFileMemberTests.cs b/ILSpy.Tests/Navigation/GoToFileMemberTests.cs
new file mode 100644
index 00000000000..4fa6d626e82
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToFileMemberTests.cs
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Input;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.FileStructure;
+using ICSharpCode.ILSpy.Properties;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToFileMemberTests
+{
+ [AvaloniaTest]
+ public async Task Typing_Filters_The_Members_Of_The_Active_Document()
+ {
+ var (_, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+ var model = new GoToFileMemberViewModel(tab);
+ model.Items.Select(e => e.Name).Should().Contain(["Circle", "radius", "Area", "Diameter"]);
+
+ // Act -- case-insensitive substring filter.
+ model.FilterText = "DIA";
+
+ // Assert -- only Diameter remains and is preselected so Enter accepts it.
+ model.Items.Select(e => e.Name).Should().Equal("Diameter");
+ model.SelectedItem.Should().BeSameAs(model.Items[0]);
+
+ model.FilterText = "zzz";
+ model.Items.Should().BeEmpty();
+ model.SelectedItem.Should().BeNull();
+ model.Accept().Should().BeFalse("nothing is selected");
+ }
+
+ [AvaloniaTest]
+ public async Task Accept_Moves_The_Caret_To_The_Member_And_Closes_The_Popup()
+ {
+ var (_, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+ var model = new GoToFileMemberViewModel(tab);
+ bool closed = false;
+ model.CloseRequested += (_, _) => closed = true;
+ model.FilterText = "area";
+ var area = model.SelectedItem!;
+
+ // Act
+ model.Accept().Should().BeTrue();
+
+ // Assert
+ closed.Should().BeTrue();
+ tab.CaptureViewState!().CaretOffset.Should().Be(area.Offset);
+ }
+
+ [AvaloniaTest]
+ public async Task Command_Opens_The_Popup_For_The_Active_Document()
+ {
+ var (vm, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+ var command = (GoToFileMemberCommand)AppComposition.Current.GetExport()
+ .GetCommand(nameof(Resources.GoToFileMember));
+
+ // Act
+ command.CanExecute(null).Should().BeTrue();
+ command.Execute(null);
+
+ // Assert -- the popup is open, bound to the active document, and listing its members.
+ command.OpenFlyout.Should().NotBeNull();
+ command.OpenFlyout!.IsOpen.Should().BeTrue();
+ command.OpenModel!.Document.Should().BeSameAs(tab);
+ command.OpenModel.Items.Should().Contain(e => e.Name == "Diameter");
+
+ // Accepting closes it.
+ command.OpenModel.FilterText = "diameter";
+ command.OpenModel.Accept().Should().BeTrue();
+ await Waiters.WaitForAsync(() => command.OpenFlyout == null, description: "the popup to close");
+ }
+
+ [Test]
+ public void Gesture_Is_Alt_Backslash()
+ {
+ // The backslash key of US and most European layouts reports as OemPipe (VK_OEM_5).
+ var gesture = KeyGesture.Parse(GoToFileMemberCommand.Gesture);
+ gesture.Key.Should().Be(Key.OemPipe);
+ gesture.KeyModifiers.Should().Be(KeyModifiers.Alt);
+ }
+
+ [AvaloniaTest]
+ public void Navigation_Gestures_Do_Not_Collide_With_Other_Commands()
+ {
+ var mine = new[] { TypeHierarchyGesture, FileStructureCommand.Gesture, GoToFileMemberCommand.Gesture }
+ .Select(KeyGesture.Parse).ToArray();
+ var mainMenu = AppComposition.Current.GetExport().Commands
+ .Select(c => (Header: c.Metadata.Header, Text: c.Metadata.InputGestureText));
+ var contextMenu = AppComposition.Current.GetExport().Entries
+ .Select(e => (Header: e.Metadata.Header, Text: e.Metadata.InputGestureText));
+ var owners = new[] { nameof(Resources.TypeHierarchy), nameof(Resources.FileStructure), nameof(Resources.GoToFileMember) };
+
+ foreach (var (header, text) in mainMenu.Concat(contextMenu))
+ {
+ if (string.IsNullOrEmpty(text) || owners.Contains(header))
+ continue;
+ KeyGesture other;
+ try
+ {
+ other = KeyGesture.Parse(text);
+ }
+ catch (System.Exception)
+ {
+ continue; // descriptive text such as "MMB", not a key gesture
+ }
+ mine.Should().NotContain(other, $"'{header}' already uses {text}");
+ }
+ }
+
+ static string TypeHierarchyGesture => TypeHierarchy.TypeHierarchyCommand.Gesture;
+}
diff --git a/ILSpy.Tests/Navigation/GoToFixture.cs b/ILSpy.Tests/Navigation/GoToFixture.cs
new file mode 100644
index 00000000000..445874768f8
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToFixture.cs
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Emits a small inheritance hierarchy for the go-to navigation tests:
+///
+/// interface IShape { double Area(); }
+/// abstract class ShapeBase : IShape { public abstract double Area(); }
+/// class Circle : ShapeBase { public override double Area(); }
+/// class Square : ShapeBase { public override double Area(); }
+/// class Lonely { public void Solo(); }
+///
+///
+public static class GoToFixture
+{
+ public const string Name = "GoToFixture";
+
+ public static string Emit()
+ {
+ var ab = new PersistedAssemblyBuilder(new AssemblyName(Name), typeof(object).Assembly);
+ var module = ab.DefineDynamicModule(Name);
+
+ var shape = module.DefineType($"{Name}.IShape",
+ TypeAttributes.Public | TypeAttributes.Interface | TypeAttributes.Abstract);
+ shape.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+ | MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+ typeof(double), Type.EmptyTypes);
+ shape.CreateType();
+
+ var shapeBase = module.DefineType($"{Name}.ShapeBase",
+ TypeAttributes.Public | TypeAttributes.Abstract | TypeAttributes.Class, typeof(object));
+ shapeBase.AddInterfaceImplementation(shape);
+ shapeBase.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+ | MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+ typeof(double), Type.EmptyTypes);
+ shapeBase.DefineDefaultConstructor(MethodAttributes.Family);
+ shapeBase.CreateType();
+
+ DefineShape(module, shapeBase, "Circle", 3.14);
+ DefineShape(module, shapeBase, "Square", 4.0);
+
+ var lonely = module.DefineType($"{Name}.Lonely", TypeAttributes.Public | TypeAttributes.Class);
+ var solo = lonely.DefineMethod("Solo", MethodAttributes.Public | MethodAttributes.HideBySig,
+ typeof(void), Type.EmptyTypes);
+ solo.GetILGenerator().Emit(OpCodes.Ret);
+ lonely.CreateType();
+
+ var dir = Path.Combine(Path.GetTempPath(), $"ILSpyGoToFixture_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, $"{Name}.dll");
+ ab.Save(path);
+ return path;
+ }
+
+ static void DefineShape(ModuleBuilder module, TypeBuilder shapeBase, string name, double area)
+ {
+ var type = module.DefineType($"{Name}.{name}", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+ var method = type.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig,
+ typeof(double), Type.EmptyTypes);
+ var il = method.GetILGenerator();
+ il.Emit(OpCodes.Ldc_R8, area);
+ il.Emit(OpCodes.Ret);
+ type.DefineDefaultConstructor(MethodAttributes.Public);
+ type.CreateType();
+ }
+
+ public static async Task OpenAsync(MainWindowViewModel vm)
+ {
+ await vm.OpenAssemblyAsync(Emit());
+ }
+
+ public static TypeTreeNode TypeNode(MainWindowViewModel vm, string typeName)
+ => vm.AssemblyTreeModel.FindNode(Name, Name, $"{Name}.{typeName}");
+
+ public static MethodTreeNode MethodNode(MainWindowViewModel vm, string typeName, string methodName)
+ {
+ var typeNode = TypeNode(vm, typeName);
+ typeNode.IsExpanded = true;
+ return typeNode.Children.OfType().Single(m => m.MethodDefinition.Name == methodName);
+ }
+
+ public static ITypeDefinition TypeDef(MainWindowViewModel vm, string typeName)
+ => TypeNode(vm, typeName).TypeDefinition;
+
+ public static IMethod Method(MainWindowViewModel vm, string typeName, string methodName)
+ => MethodNode(vm, typeName, methodName).MethodDefinition;
+
+ /// "Type.Member" or "Type" for a navigation target, for order-insensitive assertions.
+ public static string Describe(IEntity entity)
+ => entity is ITypeDefinition td ? td.Name : $"{entity.DeclaringTypeDefinition?.Name}.{entity.Name}";
+}
diff --git a/ILSpy.Tests/Navigation/GoToLineTests.cs b/ILSpy.Tests/Navigation/GoToLineTests.cs
new file mode 100644
index 00000000000..cbf32e05776
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToLineTests.cs
@@ -0,0 +1,145 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.Tests.TextView;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+///
+/// Go to Line (dotPeek Ctrl+G in the code view): a small prompt in the active decompiler view
+/// that moves the caret to "line" or "line:column".
+///
+[TestFixture]
+public class GoToLineTests
+{
+ static async Task<(MainWindow Window, DecompilerTextView View)> SetupAsync()
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "ILSpy.Tests",
+ "ICSharpCode.ILSpy.Tests.TextView",
+ "ICSharpCode.ILSpy.Tests.TextView.MemberHighlightSample");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var view = window.GetVisualDescendants().OfType().First();
+ return (window, view);
+ }
+
+ [TestCase("12", 12, null)]
+ [TestCase(" 7 ", 7, null)]
+ [TestCase("12:5", 12, 5)]
+ [TestCase("3,9", 3, 9)]
+ public void Parses_Line_And_Optional_Column(string input, int line, int? column)
+ {
+ GoToLineInput.TryParse(input, out var parsedLine, out var parsedColumn).Should().BeTrue();
+ parsedLine.Should().Be(line);
+ parsedColumn.Should().Be(column);
+ }
+
+ [TestCase("")]
+ [TestCase("abc")]
+ [TestCase("0")]
+ [TestCase("-3")]
+ [TestCase("4:0")]
+ [TestCase("4:x")]
+ public void Rejects_Invalid_Input(string input)
+ {
+ GoToLineInput.TryParse(input, out _, out _).Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public async Task GoToLine_Moves_The_Caret_To_The_Line_And_Column()
+ {
+ var (_, view) = await SetupAsync();
+ view.Editor.Document.LineCount.Should().BeGreaterThan(5);
+
+ var target = view.Editor.Document.Lines.Skip(1).First(l => l.Length >= 5).LineNumber;
+
+ view.GoToLine(target, 3).Should().BeTrue();
+
+ view.Editor.TextArea.Caret.Line.Should().Be(target);
+ view.Editor.TextArea.Caret.Column.Should().Be(3);
+ }
+
+ [AvaloniaTest]
+ public async Task GoToLine_Clamps_Out_Of_Range_Positions()
+ {
+ var (_, view) = await SetupAsync();
+ int lineCount = view.Editor.Document.LineCount;
+
+ view.GoToLine(lineCount + 100, 1000).Should().BeTrue();
+
+ view.Editor.TextArea.Caret.Line.Should().Be(lineCount);
+ var lastLine = view.Editor.Document.GetLineByNumber(lineCount);
+ view.Editor.TextArea.Caret.Column.Should().Be(lastLine.Length + 1, "the column is clamped to the end of the line");
+ }
+
+ [AvaloniaTest]
+ public async Task Ctrl_G_In_The_Editor_Opens_The_Prompt_And_Enter_Jumps()
+ {
+ var (window, view) = await SetupAsync();
+ view.Editor.TextArea.Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ window.KeyPress(Key.G, RawInputModifiers.Control, PhysicalKey.G, null);
+ Dispatcher.UIThread.RunJobs();
+
+ var prompt = window.OwnedWindows.OfType().Should().ContainSingle(
+ "Ctrl+G inside the code view opens the Go to Line prompt").Subject;
+ prompt.InputText = "5";
+ prompt.Accept();
+ Dispatcher.UIThread.RunJobs();
+
+ view.Editor.TextArea.Caret.Line.Should().Be(5);
+ window.OwnedWindows.OfType().Should().BeEmpty("accepting closes the prompt");
+ }
+
+ [AvaloniaTest]
+ public async Task Menu_Command_Opens_The_Prompt_For_The_Active_Code_View()
+ {
+ var (window, _) = await SetupAsync();
+
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToLine));
+ command.CanExecute(null).Should().BeTrue("a decompiled document is showing");
+ command.Execute(null);
+
+ var prompt = window.OwnedWindows.OfType().Should().ContainSingle().Subject;
+ prompt.Close();
+ }
+}
diff --git a/ILSpy.Tests/Navigation/GoToSymbolTests.cs b/ILSpy.Tests/Navigation/GoToSymbolTests.cs
new file mode 100644
index 00000000000..1a5b7a2a24d
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToSymbolTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToSymbolTests
+{
+ static async Task BootWithFixtureAsync()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await GoToFixture.OpenAsync(vm);
+ return vm;
+ }
+
+ static string[] Find(MainWindowViewModel vm, GoToKind kind, IEntity entity)
+ => SymbolHierarchy.Find(kind, entity, vm.AssemblyTreeModel.AssemblyList!, CancellationToken.None)
+ .Select(GoToFixture.Describe).OrderBy(s => s).ToArray();
+
+ [AvaloniaTest]
+ public async Task Implementations_Of_An_Interface_Method_Are_The_Concrete_Overrides()
+ {
+ var vm = await BootWithFixtureAsync();
+ Find(vm, GoToKind.Implementation, GoToFixture.Method(vm, "IShape", "Area"))
+ .Should().Equal("Circle.Area", "Square.Area");
+ }
+
+ [AvaloniaTest]
+ public async Task Derived_Symbols_Of_An_Interface_Method_Are_Its_Direct_Implementations()
+ {
+ var vm = await BootWithFixtureAsync();
+ Find(vm, GoToKind.DerivedSymbols, GoToFixture.Method(vm, "IShape", "Area"))
+ .Should().Equal("ShapeBase.Area");
+ Find(vm, GoToKind.DerivedSymbols, GoToFixture.Method(vm, "ShapeBase", "Area"))
+ .Should().Equal("Circle.Area", "Square.Area");
+ }
+
+ [AvaloniaTest]
+ public async Task Base_Symbols_Of_An_Override_Are_The_Overridden_And_Implemented_Members()
+ {
+ var vm = await BootWithFixtureAsync();
+ Find(vm, GoToKind.BaseSymbols, GoToFixture.Method(vm, "Circle", "Area"))
+ .Should().Equal("IShape.Area", "ShapeBase.Area");
+ }
+
+ [AvaloniaTest]
+ public async Task Type_Hierarchy_Is_Resolved_Across_The_Assembly_List()
+ {
+ var vm = await BootWithFixtureAsync();
+ Find(vm, GoToKind.Implementation, GoToFixture.TypeDef(vm, "IShape")).Should().Equal("Circle", "Square");
+ Find(vm, GoToKind.DerivedSymbols, GoToFixture.TypeDef(vm, "IShape")).Should().Equal("ShapeBase");
+ Find(vm, GoToKind.DerivedSymbols, GoToFixture.TypeDef(vm, "ShapeBase")).Should().Equal("Circle", "Square");
+ Find(vm, GoToKind.BaseSymbols, GoToFixture.TypeDef(vm, "Circle")).Should().Equal("ShapeBase");
+ Find(vm, GoToKind.BaseSymbols, GoToFixture.TypeDef(vm, "ShapeBase")).Should().Equal("IShape", "Object");
+ }
+
+ [AvaloniaTest]
+ public async Task Hierarchy_Entries_Are_Hidden_For_Symbols_That_Cannot_Have_Inheritors()
+ {
+ var vm = await BootWithFixtureAsync();
+ var registry = AppComposition.Current.GetExport();
+ var solo = GoToFixture.MethodNode(vm, "Lonely", "Solo");
+ var context = new TextViewContext { SelectedTreeNodes = [solo] };
+
+ registry.GetEntry(nameof(Resources.GoToImplementation)).IsVisible(context).Should().BeFalse();
+ registry.GetEntry(nameof(Resources.GoToDerivedSymbols)).IsVisible(context).Should().BeFalse();
+ registry.GetEntry(nameof(Resources.GoToBaseSymbols)).IsVisible(context).Should().BeFalse(
+ "a non-virtual method declared on a class with only System.Object as base has no base symbol");
+ registry.GetEntry(nameof(Resources.GoToDeclaration)).IsVisible(context).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Navigation_Entries_Share_The_Navigation_Category()
+ {
+ await TestHarness.BootAsync();
+ var registry = AppComposition.Current.GetExport();
+ foreach (var header in new[] {
+ nameof(Resources.GoToDeclaration), nameof(Resources.GoToImplementation),
+ nameof(Resources.GoToBaseSymbols), nameof(Resources.GoToDerivedSymbols),
+ nameof(Resources.LocateInAssemblyExplorer) })
+ {
+ registry.Entries.Single(e => e.Metadata.Header == header).Metadata.Category
+ .Should().Be("Navigation", $"{header} must surface in the Navigate To popup");
+ }
+ }
+
+ [AvaloniaTest]
+ public async Task Go_To_Base_On_A_Tree_Node_With_One_Target_Navigates_Directly()
+ {
+ var vm = await BootWithFixtureAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToBaseSymbols));
+ var navigator = AppComposition.Current.GetExport();
+ var circle = GoToFixture.TypeNode(vm, "Circle");
+ vm.AssemblyTreeModel.SelectNode(circle);
+ var context = new TextViewContext { SelectedTreeNodes = [circle] };
+
+ entry.IsVisible(context).Should().BeTrue();
+ entry.Execute(context);
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+ selected.Should().NotBeNull("a single base type is navigated to without a chooser");
+ GoToFixture.Describe(selected!).Should().Be("ShapeBase");
+ navigator.ActiveChooser.Should().BeNull("one target needs no chooser");
+ }
+
+ [AvaloniaTest]
+ public async Task Go_To_Implementation_On_A_Code_Reference_With_Several_Targets_Opens_A_Searchable_Chooser()
+ {
+ var vm = await BootWithFixtureAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToImplementation));
+ var navigator = AppComposition.Current.GetExport();
+ var area = GoToFixture.Method(vm, "IShape", "Area");
+ var context = new TextViewContext { Reference = new ReferenceSegment { Reference = area } };
+
+ entry.IsVisible(context).Should().BeTrue();
+ entry.Execute(context);
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ var chooser = navigator.ActiveChooser;
+ chooser.Should().NotBeNull("two implementations need a chooser");
+ chooser!.IsOpen.Should().BeTrue();
+ chooser.VisibleChoices.Should().HaveCount(2);
+
+ chooser.Filter = "Square";
+ chooser.VisibleChoices.Should().ContainSingle().Which.Text.Should().Contain("Square");
+
+ chooser.FilterBox.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter });
+ Dispatcher.UIThread.RunJobs();
+
+ chooser.IsOpen.Should().BeFalse("accepting a choice closes the chooser");
+ var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+ selected.Should().NotBeNull();
+ GoToFixture.Describe(selected!).Should().Be("Square.Area");
+ }
+
+ [AvaloniaTest]
+ public async Task Escape_Closes_The_Chooser_Without_Navigating()
+ {
+ var vm = await BootWithFixtureAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDerivedSymbols));
+ var navigator = AppComposition.Current.GetExport();
+ var shapeBase = GoToFixture.TypeNode(vm, "ShapeBase");
+ vm.AssemblyTreeModel.SelectNode(shapeBase);
+ entry.Execute(new TextViewContext { SelectedTreeNodes = [shapeBase] });
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ var chooser = navigator.ActiveChooser;
+ chooser.Should().NotBeNull();
+ chooser!.FilterBox.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Escape });
+ Dispatcher.UIThread.RunJobs();
+
+ chooser.IsOpen.Should().BeFalse();
+ ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, shapeBase).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task No_Targets_Shows_A_Notice_Instead_Of_Navigating()
+ {
+ var vm = await BootWithFixtureAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDerivedSymbols));
+ var navigator = AppComposition.Current.GetExport();
+ var circle = GoToFixture.TypeNode(vm, "Circle");
+ vm.AssemblyTreeModel.SelectNode(circle);
+ entry.Execute(new TextViewContext { SelectedTreeNodes = [circle] });
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ navigator.ActiveChooser.Should().BeNull();
+ navigator.LastNotice.Should().Be("No derived symbols found");
+ ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, circle).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Go_To_Declaration_On_A_Code_Reference_Navigates_To_The_Definition()
+ {
+ var vm = await BootWithFixtureAsync();
+ var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDeclaration));
+ var navigator = AppComposition.Current.GetExport();
+ vm.AssemblyTreeModel.SelectNode(GoToFixture.TypeNode(vm, "Lonely"));
+ var square = GoToFixture.TypeDef(vm, "Square");
+
+ entry.Execute(new TextViewContext { Reference = new ReferenceSegment { Reference = square } });
+ await navigator.LastOperation;
+ Dispatcher.UIThread.RunJobs();
+
+ GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("Square");
+ }
+}
diff --git a/ILSpy.Tests/Navigation/HierarchyFixture.cs b/ILSpy.Tests/Navigation/HierarchyFixture.cs
new file mode 100644
index 00000000000..de640900be7
--- /dev/null
+++ b/ILSpy.Tests/Navigation/HierarchyFixture.cs
@@ -0,0 +1,126 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.IO;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Threading.Tasks;
+
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Emits a small assembly with a known inheritance graph for the type-hierarchy and
+/// file-structure tests:
+///
+/// interface IShape { double Area(); }
+/// abstract class ShapeBase : IShape { abstract double Area(); }
+/// class Circle : ShapeBase { double radius; double Area(); double Diameter(); }
+/// class BigCircle : Circle { }
+/// class Square : ShapeBase { double Area(); }
+///
+/// All types live in the namespace of an assembly with the same name.
+///
+static class HierarchyFixture
+{
+ public const string Namespace = "Shapes";
+
+ public static string Emit()
+ {
+ var ab = new PersistedAssemblyBuilder(new AssemblyName(Namespace), typeof(object).Assembly);
+ var module = ab.DefineDynamicModule(Namespace);
+ var objectCtor = typeof(object).GetConstructor(Type.EmptyTypes)!;
+
+ var shape = module.DefineType($"{Namespace}.IShape",
+ TypeAttributes.Public | TypeAttributes.Interface | TypeAttributes.Abstract);
+ shape.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+ | MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+ typeof(double), Type.EmptyTypes);
+ shape.CreateType();
+
+ var shapeBase = module.DefineType($"{Namespace}.ShapeBase",
+ TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Abstract, typeof(object), [shape]);
+ shapeBase.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+ | MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+ typeof(double), Type.EmptyTypes);
+ var shapeBaseCtor = DefineCtor(shapeBase, objectCtor, MethodAttributes.Family);
+ shapeBase.CreateType();
+
+ var circle = module.DefineType($"{Namespace}.Circle", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+ var radius = circle.DefineField("radius", typeof(double), FieldAttributes.Private);
+ DefineAreaOverride(circle, 3.0);
+ var diameter = circle.DefineMethod("Diameter",
+ MethodAttributes.Public | MethodAttributes.HideBySig, typeof(double), Type.EmptyTypes);
+ var il = diameter.GetILGenerator();
+ il.Emit(OpCodes.Ldarg_0);
+ il.Emit(OpCodes.Ldfld, radius);
+ il.Emit(OpCodes.Ldc_R8, 2.0);
+ il.Emit(OpCodes.Mul);
+ il.Emit(OpCodes.Ret);
+ var circleCtor = DefineCtor(circle, shapeBaseCtor, MethodAttributes.Public);
+ circle.CreateType();
+
+ var bigCircle = module.DefineType($"{Namespace}.BigCircle", TypeAttributes.Public | TypeAttributes.Class, circle);
+ DefineCtor(bigCircle, circleCtor, MethodAttributes.Public);
+ bigCircle.CreateType();
+
+ var square = module.DefineType($"{Namespace}.Square", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+ DefineAreaOverride(square, 4.0);
+ DefineCtor(square, shapeBaseCtor, MethodAttributes.Public);
+ square.CreateType();
+
+ var dir = Path.Combine(Path.GetTempPath(), $"ILSpyHierarchyFixture_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, $"{Namespace}.dll");
+ ab.Save(path);
+ return path;
+ }
+
+ static ConstructorInfo DefineCtor(TypeBuilder type, ConstructorInfo baseCtor, MethodAttributes access)
+ {
+ var ctor = type.DefineConstructor(access | MethodAttributes.HideBySig | MethodAttributes.SpecialName
+ | MethodAttributes.RTSpecialName, CallingConventions.Standard, Type.EmptyTypes);
+ var il = ctor.GetILGenerator();
+ il.Emit(OpCodes.Ldarg_0);
+ il.Emit(OpCodes.Call, baseCtor);
+ il.Emit(OpCodes.Ret);
+ return ctor;
+ }
+
+ static void DefineAreaOverride(TypeBuilder type, double value)
+ {
+ var area = type.DefineMethod("Area",
+ MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig,
+ typeof(double), Type.EmptyTypes);
+ var il = area.GetILGenerator();
+ il.Emit(OpCodes.Ldc_R8, value);
+ il.Emit(OpCodes.Ret);
+ }
+
+ /// Opens the fixture through the production Open command.
+ public static Task OpenAsync(MainWindowViewModel vm) => vm.OpenAssemblyAsync(Emit());
+
+ /// The assembly-tree node of the fixture type named .
+ public static TypeTreeNode FindType(MainWindowViewModel vm, string name)
+ => vm.AssemblyTreeModel.FindNode(Namespace, Namespace, $"{Namespace}.{name}");
+}
diff --git a/ILSpy.Tests/Navigation/RecentFilesTests.cs b/ILSpy.Tests/Navigation/RecentFilesTests.cs
new file mode 100644
index 00000000000..f1d99885cc7
--- /dev/null
+++ b/ILSpy.Tests/Navigation/RecentFilesTests.cs
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Input;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX.TreeView;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+///
+/// Recent Files (dotPeek "Recent Files"): every tree node the user navigates to lands at the top
+/// of a distinct, most-recent-first list, and the popup over that list navigates back to the
+/// chosen node.
+///
+[TestFixture]
+public class RecentFilesTests
+{
+ // Namespace nodes decompile to a single comment line, the cheapest navigation targets.
+ static (NamespaceTreeNode A, NamespaceTreeNode B, NamespaceTreeNode C) CheapNodes(AssemblyTreeModel atm)
+ => (atm.FindNode(TreeNavigation.CoreLibName, "System.Runtime.Versioning"),
+ atm.FindNode(TreeNavigation.CoreLibName, "System.Text"),
+ atm.FindNode(TreeNavigation.CoreLibName, "System.Globalization"));
+
+ [AvaloniaTest]
+ public async Task Navigated_Nodes_Are_Listed_Most_Recent_First_Without_Duplicates()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var dock = vm.DockWorkspace;
+ var (a, b, c) = CheapNodes(vm.AssemblyTreeModel);
+
+ foreach (var node in new[] { a, b, c, a })
+ {
+ vm.AssemblyTreeModel.SelectNode(node);
+ await dock.WaitForDecompiledTextAsync();
+ }
+
+ var files = dock.RecentNavigation.Files.Select(f => f.Node).ToList();
+ files.Take(3).Should().Equal(new SharpTreeNode[] { a, c, b },
+ "re-visiting A must move it to the top instead of adding a second entry");
+ files.Count(n => ReferenceEquals(n, a)).Should().Be(1);
+ }
+
+ [AvaloniaTest]
+ public async Task Popup_Filters_The_List_And_Accepting_An_Entry_Navigates_To_It()
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ var dock = vm.DockWorkspace;
+ var (a, b, _) = CheapNodes(vm.AssemblyTreeModel);
+ vm.AssemblyTreeModel.SelectNode(a);
+ await dock.WaitForDecompiledTextAsync();
+ vm.AssemblyTreeModel.SelectNode(b);
+ await dock.WaitForDecompiledTextAsync();
+
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.RecentFiles));
+ command.Execute(null);
+
+ var popup = window.OwnedWindows.OfType().Should().ContainSingle(
+ "Recent Files must open a quick-pick popup owned by the main window").Subject;
+ popup.Model.FilteredItems.Should().NotBeEmpty();
+ var first = popup.Model.FilteredItems[0].Payload.Should().BeOfType().Subject;
+ ReferenceEquals(first.Node, b).Should().BeTrue("the most recent entry comes first");
+
+ popup.Model.Filter = "Versioning";
+ popup.Model.FilteredItems.Should().ContainSingle("the filter must narrow the list to matching entries")
+ .Which.Text.Should().Contain("System.Runtime.Versioning");
+
+ popup.Model.Accept();
+ await dock.WaitForDecompiledTextAsync();
+
+ ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, a).Should().BeTrue("accepting an entry navigates to its node");
+ window.OwnedWindows.OfType().Should().BeEmpty("accepting closes the popup");
+ }
+
+ [AvaloniaTest]
+ public void Model_Filter_Matches_Text_And_Detail_Case_Insensitively()
+ {
+ var model = new QuickPickModel(new[] {
+ new QuickPickItem("Alpha", "first.dll", null, 1),
+ new QuickPickItem("Beta", "second.dll", null, 2),
+ new QuickPickItem("Gamma", "SECOND.dll", null, 3),
+ }, _ => { });
+
+ model.FilteredItems.Should().HaveCount(3);
+ model.SelectedItem.Should().BeSameAs(model.FilteredItems[0], "the first entry is preselected");
+
+ model.Filter = "second";
+ model.FilteredItems.Select(i => i.Text).Should().Equal("Beta", "Gamma");
+ model.SelectedItem!.Text.Should().Be("Beta", "the selection follows the filtered list");
+
+ model.Filter = "zzz";
+ model.FilteredItems.Should().BeEmpty();
+ model.SelectedItem.Should().BeNull();
+ }
+
+ [AvaloniaTest]
+ public async Task Navigation_Shortcuts_Are_Bound_On_The_Main_Window()
+ {
+ var (window, _) = await TestHarness.BootAsync();
+
+ bool Bound(Key key, KeyModifiers modifiers)
+ => window.KeyBindings.Any(b => b.Gesture is { } g && g.Key == key && g.KeyModifiers == modifiers);
+
+ Bound(Key.OemComma, KeyModifiers.Control).Should().BeTrue("Recent Files is Ctrl+Comma");
+ Bound(Key.OemComma, KeyModifiers.Control | KeyModifiers.Shift).Should().BeTrue("Recent Locations is Ctrl+Shift+Comma");
+ Bound(Key.F2, KeyModifiers.None).Should().BeTrue("Find Usages for Rename is F2");
+ Bound(Key.G, KeyModifiers.Control).Should().BeFalse(
+ "Ctrl+G is Go to Line only inside the code view; metadata grids use it for Go to token");
+ }
+}
diff --git a/ILSpy.Tests/Navigation/RecentLocationsTests.cs b/ILSpy.Tests/Navigation/RecentLocationsTests.cs
new file mode 100644
index 00000000000..30270ebf12e
--- /dev/null
+++ b/ILSpy.Tests/Navigation/RecentLocationsTests.cs
@@ -0,0 +1,127 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX.TreeView;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+///
+/// Recent Locations (dotPeek "Recent Locations"): the caret position the user leaves behind on
+/// each navigation is remembered together with a one-line preview of the code there, and
+/// choosing a location re-opens its node with the caret restored.
+///
+[TestFixture]
+public class RecentLocationsTests
+{
+ static (NamespaceTreeNode A, NamespaceTreeNode B) CheapNodes(AssemblyTreeModel atm)
+ => (atm.FindNode(TreeNavigation.CoreLibName, "System.Runtime.Versioning"),
+ atm.FindNode(TreeNavigation.CoreLibName, "System.Text"));
+
+ [AvaloniaTest]
+ public async Task Leaving_A_Node_Records_Its_Caret_Location_With_A_Code_Preview()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var dock = vm.DockWorkspace;
+ var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+ vm.AssemblyTreeModel.SelectNode(a);
+ var tab = await dock.WaitForDecompiledTextAsync();
+ var text = tab.Text;
+ // Headless has no laid-out editor; report the caret the user "left" on node A.
+ tab.CaptureViewState = () => new DecompilerTextViewState(4, 0, 0, null);
+
+ vm.AssemblyTreeModel.SelectNode(b);
+ await dock.WaitForDecompiledTextAsync();
+
+ var location = dock.RecentNavigation.Locations.Should().NotBeEmpty().And.Subject.First();
+ ReferenceEquals(location.Node, a).Should().BeTrue();
+ location.State.CaretOffset.Should().Be(4);
+ location.Line.Should().Be(1);
+ location.Column.Should().Be(5);
+ location.Preview.Should().Be(text.Split('\n')[0].Trim(),
+ "the preview is the trimmed text of the caret line");
+ }
+
+ [AvaloniaTest]
+ public async Task Navigating_To_A_Location_Reopens_The_Node_And_Restores_The_Caret()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var dock = vm.DockWorkspace;
+ var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+ vm.AssemblyTreeModel.SelectNode(a);
+ var tab = await dock.WaitForDecompiledTextAsync();
+ tab.CaptureViewState = () => new DecompilerTextViewState(6, 12.5, 0, null);
+ vm.AssemblyTreeModel.SelectNode(b);
+ await dock.WaitForDecompiledTextAsync();
+
+ var location = dock.RecentNavigation.Locations.First(l => ReferenceEquals(l.Node, a));
+ dock.NavigateToRecentLocation(location);
+
+ ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, a).Should().BeTrue();
+ var target = dock.ActiveDecompilerTab!;
+ target.PendingViewState.Should().NotBeNull("the location's view state is handed to the editor");
+ target.PendingViewState!.Value.CaretOffset.Should().Be(6);
+ target.PendingViewState!.Value.VerticalOffset.Should().Be(12.5);
+
+ await dock.WaitForDecompiledTextAsync();
+ target.PendingViewState.Should().BeNull("the editor consumes the restored state");
+ }
+
+ [AvaloniaTest]
+ public async Task Popup_Lists_Locations_With_Their_Preview_And_Includes_The_Current_One()
+ {
+ var (window, vm) = await TestHarness.BootAsync();
+ var dock = vm.DockWorkspace;
+ var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+ vm.AssemblyTreeModel.SelectNode(a);
+ var tabA = await dock.WaitForDecompiledTextAsync();
+ tabA.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, null);
+ vm.AssemblyTreeModel.SelectNode(b);
+ var tabB = await dock.WaitForDecompiledTextAsync();
+ tabB.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, null);
+
+ var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.RecentLocations));
+ command.Execute(null);
+
+ var popup = window.OwnedWindows.OfType().Should().ContainSingle().Subject;
+ var locations = popup.Model.FilteredItems.Select(i => i.Payload).OfType().ToList();
+ locations.Select(l => l.Node).Should().ContainInOrder(new SharpTreeNode[] { b, a },
+ "the current location is listed first, then the one left behind");
+ popup.Model.FilteredItems[0].Detail.Should().Contain(tabB.Text.Split('\n')[0].Trim(),
+ "each entry shows the code at its position");
+ popup.Close();
+ }
+}
diff --git a/ILSpy.Tests/Navigation/TypeHierarchyTests.cs b/ILSpy.Tests/Navigation/TypeHierarchyTests.cs
new file mode 100644
index 00000000000..9ff35ecdb8d
--- /dev/null
+++ b/ILSpy.Tests/Navigation/TypeHierarchyTests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.TypeHierarchy;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class TypeHierarchyTests
+{
+ static async Task<(MainWindowViewModel Vm, TypeHierarchyViewModel Pane)> ShowForSelectedAsync(string typeName)
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, typeName));
+ AppComposition.Current.GetExport()
+ .GetCommand(nameof(Resources.TypeHierarchy)).Execute(null);
+ var pane = AppComposition.Current.GetExport();
+ pane.Target.Should().NotBeNull("the command must target the selected type");
+ await pane.Target!.LoadChildrenAsync();
+ return (vm, pane);
+ }
+
+ static TypeHierarchyNode Group(TypeHierarchyNode target, TypeHierarchyNodeKind kind)
+ => target.Children.Single(c => c.Kind == kind);
+
+ static string[] TypeNames(TypeHierarchyNode group)
+ => group.Children.Select(c => c.Type!.FullName).ToArray();
+
+ [AvaloniaTest]
+ public async Task Command_Shows_Pane_With_Base_Types_And_Derived_Types_Of_Selected_Type()
+ {
+ // Arrange + Act -- select ShapeBase in the tree and run Navigate > Type Hierarchy.
+ var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+
+ // Assert -- the pane is docked, targets ShapeBase, lists its direct supertypes (the base
+ // class and the implemented interface) and, once the background search finishes, its
+ // direct subclasses.
+ vm.DockWorkspace.ToolPaneMenuItems.Single(i => i.Title == Resources.TypeHierarchy)
+ .IsPaneVisible.Should().BeTrue();
+ pane.Target!.Type!.FullName.Should().Be("Shapes.ShapeBase");
+ pane.Roots.Should().ContainSingle().Which.Should().BeSameAs(pane.Target);
+
+ var bases = Group(pane.Target, TypeHierarchyNodeKind.BaseTypesGroup);
+ TypeNames(bases).Should().BeEquivalentTo("System.Object", "Shapes.IShape");
+
+ var derived = Group(pane.Target, TypeHierarchyNodeKind.DerivedTypesGroup);
+ await derived.LoadChildrenAsync();
+ TypeNames(derived).Should().BeEquivalentTo("Shapes.Circle", "Shapes.Square");
+ derived.Children.Should().OnlyContain(c => c.Kind == TypeHierarchyNodeKind.DerivedType && c.Icon != null);
+ }
+
+ [AvaloniaTest]
+ public async Task Derived_Nodes_Expand_To_Their_Own_Subtypes_And_Base_Nodes_To_Their_Supertypes()
+ {
+ var (_, pane) = await ShowForSelectedAsync("ShapeBase");
+
+ // Act -- drill one level further both ways.
+ var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+ await derived.LoadChildrenAsync();
+ var circle = derived.Children.Single(c => c.Type!.Name == "Circle");
+ await circle.LoadChildrenAsync();
+
+ var bases = Group(pane.Target!, TypeHierarchyNodeKind.BaseTypesGroup);
+ var iShape = bases.Children.Single(c => c.Type!.Name == "IShape");
+ await iShape.LoadChildrenAsync();
+
+ // Assert -- Circle's subtypes are listed below it; IShape has no supertypes.
+ TypeNames(circle).Should().Equal("Shapes.BigCircle");
+ iShape.Children.Should().BeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Interface_Target_Lists_Its_Implementations()
+ {
+ var (_, pane) = await ShowForSelectedAsync("IShape");
+
+ var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+ await derived.LoadChildrenAsync();
+
+ TypeNames(derived).Should().Equal("Shapes.ShapeBase");
+ Group(pane.Target!, TypeHierarchyNodeKind.BaseTypesGroup).Children.Should().BeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Member_Selection_Targets_The_Declaring_Type()
+ {
+ // Arrange -- select a method of Circle rather than the type itself.
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ var circleNode = HierarchyFixture.FindType(vm, "Circle");
+ var method = circleNode.GetChild(m => m.MethodDefinition.Name == "Diameter");
+ vm.AssemblyTreeModel.SelectNode(method);
+
+ // Act
+ AppComposition.Current.GetExport()
+ .GetCommand(nameof(Resources.TypeHierarchy)).Execute(null);
+
+ // Assert
+ var pane = AppComposition.Current.GetExport();
+ pane.Target!.Type!.FullName.Should().Be("Shapes.Circle");
+ }
+
+ [AvaloniaTest]
+ public async Task Activating_A_Node_Navigates_The_Assembly_Tree_And_Records_History()
+ {
+ var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+ var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+ await derived.LoadChildrenAsync();
+ var square = derived.Children.Single(c => c.Type!.Name == "Square");
+ // NavigationHistory merges selections within 0.5s into one entry.
+ await Task.Delay(600);
+
+ // Act
+ pane.Activate(square).Should().BeTrue();
+
+ // Assert -- the tree selection moves to Square through the normal navigation path, so
+ // Back returns to the previous selection.
+ var selected = vm.AssemblyTreeModel.SelectedItem as TypeTreeNode;
+ Assert.That(selected, Is.Not.Null, "the selection must be a type node");
+ selected!.TypeDefinition.FullName.Should().Be("Shapes.Square");
+ vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Pane_View_Renders_The_Hierarchy_And_Enter_Navigates_To_The_Selected_Type()
+ {
+ var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+ var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+ await derived.LoadChildrenAsync();
+ var circle = derived.Children.Single(c => c.Type!.Name == "Circle");
+ var window = AppComposition.Current.GetExport();
+ var view = await window.WaitForComponent();
+ var tree = view.FindControl("HierarchyTree")!;
+ tree.ItemsSource.Should().BeSameAs(pane.Roots);
+ await Task.Delay(600);
+
+ // Act -- select Circle and press Enter in the tree.
+ pane.SelectedNode = circle;
+ Dispatcher.UIThread.RunJobs();
+ tree.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter, Source = tree });
+
+ // Assert
+ var selected = vm.AssemblyTreeModel.SelectedItem as TypeTreeNode;
+ Assert.That(selected, Is.Not.Null, "the selection must be a type node");
+ selected!.TypeDefinition.FullName.Should().Be("Shapes.Circle");
+ }
+
+ [AvaloniaTest]
+ public async Task Retargeting_Cancels_The_Previous_Derived_Type_Search()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ var pane = AppComposition.Current.GetExport();
+ var first = HierarchyFixture.FindType(vm, "ShapeBase").TypeDefinition;
+ var second = HierarchyFixture.FindType(vm, "Circle").TypeDefinition;
+
+ // Act -- start a search, then retarget before it can be observed.
+ pane.ShowHierarchy(first);
+ var firstToken = pane.SearchCancellationToken;
+ pane.ShowHierarchy(second);
+
+ // Assert
+ firstToken.IsCancellationRequested.Should().BeTrue();
+ pane.SearchCancellationToken.IsCancellationRequested.Should().BeFalse();
+ pane.Target!.Type!.FullName.Should().Be("Shapes.Circle");
+ }
+
+ [AvaloniaTest]
+ public async Task Code_Reference_Resolves_To_A_Type_For_The_Context_Menu_Entry()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await HierarchyFixture.OpenAsync(vm);
+ var square = HierarchyFixture.FindType(vm, "Square").TypeDefinition;
+ var area = square.Methods.Single(m => m.Name == "Area");
+ var entry = AppComposition.Current.GetExport().Entries
+ .Single(e => e.Metadata.Header == nameof(Resources.TypeHierarchy));
+
+ // The Navigation category is what lists the entry in the Navigate To popup.
+ entry.Metadata.Category.Should().Be("Navigation");
+
+ var memberContext = new TextViewContext { Reference = new ReferenceSegment { Reference = area } };
+ entry.Value.IsVisible(memberContext).Should().BeTrue();
+ TypeHierarchyViewModel.ResolveType(area)!.FullName.Should().Be("Shapes.Square");
+ TypeHierarchyViewModel.ResolveType((IType)square)!.FullName.Should().Be("Shapes.Square");
+
+ var localContext = new TextViewContext { Reference = new ReferenceSegment { Reference = "local" } };
+ entry.Value.IsVisible(localContext).Should().BeFalse();
+
+ // Act
+ entry.Value.Execute(memberContext);
+
+ // Assert
+ AppComposition.Current.GetExport().Target!.Type!.FullName.Should().Be("Shapes.Square");
+ }
+
+ [Test]
+ public void Gesture_Is_Ctrl_Alt_H()
+ {
+ var gesture = KeyGesture.Parse(TypeHierarchyCommand.Gesture);
+ gesture.Key.Should().Be(Key.H);
+ gesture.KeyModifiers.Should().Be(KeyModifiers.Control | KeyModifiers.Alt);
+ }
+}
diff --git a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
index 49861418d40..c01e31e3065 100644
--- a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
+++ b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
@@ -69,8 +69,9 @@ public static bool IsVisibleForContext(TextViewContext context)
if (context.SelectedTreeNodes is { Length: > 0 } nodes)
{
// Top-level analyzer rows are already analysed (Remove is the entry for those);
- // result rows underneath promote their entity to a new top-level row.
- return nodes.All(n => n is IMemberTreeNode
+ // result rows underneath promote their entity to a new top-level row. Assembly
+ // nodes analyse the whole module.
+ return nodes.All(n => (n is IMemberTreeNode || n is AssemblyTreeNode)
&& n is not AnalyzerEntityTreeNode { Parent.IsRoot: true });
}
// Right-clicking a resolved symbol in the decompiled code: the reference carries the entity.
@@ -80,7 +81,7 @@ public static bool IsVisibleForContext(TextViewContext context)
public static bool IsEnabledForContext(TextViewContext context)
{
if (context.SelectedTreeNodes is { Length: > 0 } nodes)
- return nodes.OfType().All(n => IsAnalysable(n.Member));
+ return nodes.All(n => SymbolOf(n) is { } symbol && IsAnalysable(symbol));
return context.Reference?.Reference is IEntity entity && IsAnalysable(entity);
}
@@ -100,28 +101,44 @@ public static bool Analyze(TextViewContext context, AnalyzerTreeViewModel analyz
return false;
}
- // The analysable entities for this invocation: a tree-node selection (assembly/analyzer tree),
+ // The analysable symbols for this invocation: a tree-node selection (assembly/analyzer tree),
// or the single resolved entity under a right-clicked code reference (decompiler text view).
- static System.Collections.Generic.List MembersToAnalyse(TextViewContext context)
+ static System.Collections.Generic.List MembersToAnalyse(TextViewContext context)
{
if (context.SelectedTreeNodes is { Length: > 0 } nodes)
{
- return nodes.OfType()
- .Select(n => n.Member)
+ return nodes.Select(SymbolOf)
.Where(IsAnalysable)
.Select(m => m!)
.ToList();
}
if (context.Reference?.Reference is IEntity entity && IsAnalysable(entity))
- return new System.Collections.Generic.List { entity };
- return new System.Collections.Generic.List();
+ return new System.Collections.Generic.List { entity };
+ return new System.Collections.Generic.List();
}
+ ///
+ /// The symbol a selected node stands for: the member of a member row, the module of an
+ /// assembly node or of a module row in the analyzer pane.
+ ///
+ internal static ISymbol? SymbolOf(ICSharpCode.ILSpyX.TreeView.SharpTreeNode node) => node switch {
+ ICSharpCode.ILSpy.Analyzers.TreeNodes.AnalyzedModuleTreeNode moduleNode => moduleNode.Module,
+ IMemberTreeNode memberNode => memberNode.Member,
+ AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode
+ => assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule,
+ _ => null,
+ };
+
///
/// Const fields are textual literals at every use-site rather than entities the
- /// analyser can match against — exclude them so the entry stays disabled.
+ /// analyser can match against, so they are excluded and the entry stays disabled. Modules
+ /// are analysable when they carry metadata.
///
- static bool IsAnalysable(IEntity? entity) => entity is not null and not IField { IsConst: true };
+ static bool IsAnalysable(ISymbol? symbol) => symbol switch {
+ IModule module => module.MetadataFile != null,
+ IEntity entity => entity is not IField { IsConst: true },
+ _ => false,
+ };
}
[Export]
diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
index cc796902bcc..05745b8f98a 100644
--- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
+++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
@@ -84,6 +84,41 @@ public AnalyzerEntityTreeNode Analyze(IEntity entity)
return node;
}
+ ///
+ /// Adds a whole module (assembly) to the analyzer pane, reusing the existing row for the same
+ /// loaded file, and selects it. Its children are the analyzers that apply to modules
+ /// ("Referenced By", "Dependent Code", ...).
+ ///
+ public AnalyzerEntityTreeNode Analyze(IModule module)
+ {
+ ArgumentNullException.ThrowIfNull(module);
+ var existing = Root.Children
+ .OfType()
+ .FirstOrDefault(n => n.Module.MetadataFile != null
+ && ReferenceEquals(n.Module.MetadataFile, module.MetadataFile));
+ if (existing != null)
+ {
+ SyncSelection(existing);
+ return existing;
+ }
+ var node = new AnalyzedModuleTreeNode(module, source: null);
+ Root.Children.Add(node);
+ node.IsExpanded = true;
+ SyncSelection(node);
+ return node;
+ }
+
+ /// Dispatches to or .
+ public AnalyzerEntityTreeNode Analyze(ISymbol symbol)
+ {
+ return symbol switch {
+ IEntity entity => Analyze(entity),
+ IModule module => Analyze(module),
+ _ => throw new ArgumentOutOfRangeException(nameof(symbol),
+ $"Symbol {symbol?.GetType().FullName} is not supported by the analyzer pane.")
+ };
+ }
+
static bool IsSameEntity(IEntity? a, IEntity b)
{
// Entities reaching the pane come from different type systems (the assembly tree's,
diff --git a/ILSpy/Dependencies/DependencyCommands.cs b/ILSpy/Dependencies/DependencyCommands.cs
new file mode 100644
index 00000000000..07d4f031d9a
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyCommands.cs
@@ -0,0 +1,196 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 System.Linq;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+ /// Shared plumbing of the dependency context-menu entries.
+ static class DependencyMenuHelpers
+ {
+ /// Category shared by the entries that also appear in the Navigate To popup.
+ public const string NavigationCategory = "Navigation";
+
+ ///
+ /// The module a single selected node stands for: an assembly node's own module, or the
+ /// assembly an assembly-reference node resolves to. Null when there is none.
+ ///
+ public static IModule? GetModule(TextViewContext context)
+ {
+ if (context.SelectedTreeNodes is not [var node])
+ return null;
+ switch (node)
+ {
+ case AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode:
+ return assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule;
+ case AssemblyReferenceTreeNode referenceNode:
+ var owner = referenceNode.AncestorsAndSelf().OfType().FirstOrDefault();
+ if (owner == null)
+ return null;
+ var resolved = owner.LoadedAssembly.GetAssemblyResolver().Resolve(referenceNode.AssemblyReference);
+ if (resolved == null)
+ return null;
+ var loaded = owner.LoadedAssembly.AssemblyList.FindAssembly(resolved.FileName);
+ return loaded?.GetTypeSystemOrNull()?.MainModule;
+ default:
+ return null;
+ }
+ }
+
+ public static bool IsModuleSelection(TextViewContext context)
+ => context.SelectedTreeNodes is [AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }]
+ or [AssemblyReferenceTreeNode];
+
+ ///
+ /// Adds to the analyzer pane, expands the analyzer row with the
+ /// given header, selects it and brings the pane to the front.
+ ///
+ public static AnalyzerSearchTreeNode? AnalyzeWith(IModule module, string analyzerHeader,
+ AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace)
+ {
+ var moduleNode = analyzerTreeViewModel.Analyze(module);
+ moduleNode.EnsureLazyChildren();
+ var row = moduleNode.Children.OfType()
+ .FirstOrDefault(r => r.AnalyzerHeader == analyzerHeader);
+ if (row != null)
+ {
+ row.IsExpanded = true;
+ analyzerTreeViewModel.SelectedItems.Clear();
+ analyzerTreeViewModel.SelectedItems.Add(row);
+ }
+ dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
+ return row;
+ }
+ }
+
+ /// Right-click an assembly (or an assembly reference) -> "Referenced By" in the analyzer pane.
+ [ExportContextMenuEntry(Header = nameof(Resources.ReferencedBy), Category = DependencyMenuHelpers.NavigationCategory, Order = 600)]
+ [Shared]
+ [method: ImportingConstructor]
+ public sealed class ReferencedByContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry
+ {
+ public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context);
+
+ public bool IsEnabled(TextViewContext context) => true;
+
+ public void Execute(TextViewContext context)
+ {
+ if (DependencyMenuHelpers.GetModule(context) is { } module)
+ DependencyMenuHelpers.AnalyzeWith(module, ModuleAnalyzerHeaders.ReferencedBy, analyzerTreeViewModel, dockWorkspace);
+ }
+ }
+
+ /// Right-click an assembly (or an assembly reference) -> "Dependent Code" in the analyzer pane.
+ [ExportContextMenuEntry(Header = nameof(Resources.DependentCode), Category = DependencyMenuHelpers.NavigationCategory, Order = 610)]
+ [Shared]
+ [method: ImportingConstructor]
+ public sealed class DependentCodeContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry
+ {
+ public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context);
+
+ public bool IsEnabled(TextViewContext context) => true;
+
+ public void Execute(TextViewContext context)
+ {
+ if (DependencyMenuHelpers.GetModule(context) is { } module)
+ DependencyMenuHelpers.AnalyzeWith(module, ModuleAnalyzerHeaders.DependentCode, analyzerTreeViewModel, dockWorkspace);
+ }
+ }
+
+ /// Right-click one or more assemblies -> "Show Assembly Dependency Diagram".
+ [ExportContextMenuEntry(Header = nameof(Resources.AssemblyDependencyDiagram), Category = DependencyMenuHelpers.NavigationCategory, Order = 620)]
+ [Shared]
+ public sealed class AssemblyDependencyDiagramContextMenuEntry : IContextMenuEntry
+ {
+ public bool IsVisible(TextViewContext context)
+ => context.SelectedTreeNodes is { Length: > 0 } nodes
+ && nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true });
+
+ public bool IsEnabled(TextViewContext context) => true;
+
+ public void Execute(TextViewContext context)
+ {
+ var roots = DependencyDiagramActions.GetTargetAssemblies(context.SelectedTreeNodes, list: null);
+ DependencyDiagramActions.ShowDiagramAsync(roots).HandleExceptions();
+ }
+ }
+
+ /// Navigate -> "Show Assembly Dependency Diagram" for the selected assemblies, or the whole list.
+ [ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.AssemblyDependencyDiagram), MenuCategory = "Dependencies", MenuOrder = 600)]
+ [Shared]
+ [method: ImportingConstructor]
+ public sealed class AssemblyDependencyDiagramCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+ {
+ public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+ public override void Execute(object? parameter)
+ {
+ var roots = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList);
+ DependencyDiagramActions.ShowDiagramAsync(roots).HandleExceptions();
+ }
+ }
+
+ ///
+ /// Navigate -> "Export Dependency Diagram (Mermaid HTML)..." for the selected assemblies, or the
+ /// whole list. A string parameter is used as the output path instead of asking for one.
+ ///
+ [ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.ExportDependencyDiagramMermaid), MenuCategory = "Dependencies", MenuOrder = 610)]
+ [Shared]
+ [method: ImportingConstructor]
+ public sealed class ExportDependencyDiagramMermaidCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+ {
+ public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+ public override void Execute(object? parameter)
+ {
+ var roots = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList);
+ DependencyDiagramActions.ExportMermaidAsync(roots, parameter as string).HandleExceptions();
+ }
+ }
+
+ ///
+ /// Navigate -> "Unresolved References Report" for the selected assembly nodes, or the whole list
+ /// when no assembly node is selected.
+ ///
+ [ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.UnresolvedReferencesReport), MenuCategory = "Dependencies", MenuOrder = 620)]
+ [Shared]
+ [method: ImportingConstructor]
+ public sealed class UnresolvedReferencesReportCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+ {
+ public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+ public override void Execute(object? parameter)
+ {
+ var assemblies = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList,
+ includeOwnersOfSelection: false);
+ DependencyDiagramActions.ShowUnresolvedReferencesReportAsync(assemblies).HandleExceptions();
+ }
+ }
+}
diff --git a/ILSpy/Dependencies/DependencyDiagramActions.cs b/ILSpy/Dependencies/DependencyDiagramActions.cs
new file mode 100644
index 00000000000..952e3c32b19
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyDiagramActions.cs
@@ -0,0 +1,178 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Util;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+ ///
+ /// The actions behind the dependency menu entries and commands: open the in-app dependency
+ /// diagram, export it as Mermaid HTML, and show the unresolved-references report. The heavy
+ /// part of each (resolving references) runs off the UI thread.
+ ///
+ public static class DependencyDiagramActions
+ {
+ ///
+ /// The assemblies an action applies to: the selected assembly nodes (with
+ /// , also the assemblies owning any other selected
+ /// node), or every successfully loaded assembly of when that yields none.
+ ///
+ public static IReadOnlyList GetTargetAssemblies(IEnumerable? selection, AssemblyList? list,
+ bool includeOwnersOfSelection = true)
+ {
+ var selected = (selection ?? Enumerable.Empty())
+ .Select(n => includeOwnersOfSelection
+ ? n.AncestorsAndSelf().OfType().FirstOrDefault()
+ : n as AssemblyTreeNode)
+ .Where(n => n is { LoadedAssembly.IsLoadedAsValidAssembly: true })
+ .Select(n => n!.LoadedAssembly)
+ .Distinct()
+ .ToList();
+ if (selected.Count > 0)
+ return selected;
+ return list?.GetAssemblies().Where(a => a.IsLoadedAsValidAssembly).ToList()
+ ?? (IReadOnlyList)Array.Empty();
+ }
+
+ /// Title of a diagram/report over .
+ public static string DescribeRoots(IReadOnlyList roots)
+ {
+ if (roots.Count == 1)
+ return roots[0].ShortName;
+ var list = roots.FirstOrDefault()?.AssemblyList;
+ return list != null && roots.Count == list.GetAssemblies().Count(a => a.IsLoadedAsValidAssembly)
+ ? list.ListName
+ : $"{roots.Count} assemblies";
+ }
+
+ ///
+ /// Builds the transitive dependency graph of off the UI thread and
+ /// opens it in a new document tab. Returns the tab content, or null when there is nothing to show.
+ ///
+ public static async Task ShowDiagramAsync(IReadOnlyList roots)
+ {
+ ArgumentNullException.ThrowIfNull(roots);
+ var dockWorkspace = AppComposition.TryGetExport();
+ if (roots.Count == 0 || dockWorkspace == null)
+ return null;
+ var graph = await Task.Run(() => AssemblyDependencyGraph.Build(roots, transitive: true)).ConfigureAwait(true);
+ var content = new DependencyGraphViewModel(graph, roots, $"Dependencies: {DescribeRoots(roots)}");
+ content.Tab = dockWorkspace.OpenNewTab(content);
+ return content;
+ }
+
+ ///
+ /// Writes the dependency graph of as a Mermaid HTML page to
+ /// (asking for a file when it is null), then shows a short report
+ /// tab offering to open the page. Returns the written path, or null when cancelled.
+ ///
+ public static async Task ExportMermaidAsync(IReadOnlyList roots, string? path)
+ {
+ ArgumentNullException.ThrowIfNull(roots);
+ if (roots.Count == 0)
+ return null;
+ string title = $"Assembly dependencies: {DescribeRoots(roots)}";
+ if (path == null)
+ {
+ path = await FilePickers.SaveAsync("HTML files|*.html", DescribeRoots(roots) + ".dependencies.html").ConfigureAwait(true);
+ if (string.IsNullOrEmpty(path))
+ return null;
+ }
+ string target = path;
+ await Task.Run(() => {
+ var graph = AssemblyDependencyGraph.Build(roots, transitive: true);
+ // Written next to the target and moved into place, so the page never appears half-written.
+ string temp = target + ".tmp";
+ File.WriteAllText(temp, DependencyGraphMermaidWriter.ToHtml(graph, title), new UTF8Encoding(false));
+ File.Move(temp, target, overwrite: true);
+ }).ConfigureAwait(true);
+
+ var dockWorkspace = AppComposition.TryGetExport();
+ if (dockWorkspace != null)
+ {
+ var output = new AvaloniaEditTextOutput { Title = Resources.ExportDependencyDiagramMermaid };
+ output.WriteLine("Dependency diagram written to:");
+ output.WriteLine(target);
+ output.WriteLine();
+ output.AddButton(null, "Open in Browser", (_, _) => ShellHelper.OpenWithDefaultApplication(target));
+ output.WriteLine();
+ output.AddRevealFileButton(target);
+ dockWorkspace.ShowTextInNewTab(Resources.ExportDependencyDiagramMermaid, output);
+ }
+ return target;
+ }
+
+ /// Opens the unresolved-references report for in a new tab.
+ public static Task ShowUnresolvedReferencesReportAsync(IReadOnlyList assemblies)
+ {
+ ArgumentNullException.ThrowIfNull(assemblies);
+ var dockWorkspace = AppComposition.TryGetExport();
+ if (dockWorkspace == null)
+ return Task.CompletedTask;
+ return dockWorkspace.RunInNewTabAsync(Resources.UnresolvedReferencesReport,
+ token => Task.Run(() => CreateUnresolvedReferencesReport(assemblies, token), token));
+ }
+
+ /// Renders the unresolved-references report, grouped by referencing assembly.
+ internal static AvaloniaEditTextOutput CreateUnresolvedReferencesReport(IReadOnlyList assemblies, CancellationToken token)
+ {
+ var groups = UnresolvedReferencesReport.Collect(assemblies, token);
+ var output = new AvaloniaEditTextOutput { Title = Resources.UnresolvedReferencesReport };
+ int total = groups.Sum(g => g.References.Count);
+ output.WriteLine($"{total} unresolved assembly reference(s) in {groups.Count} of {assemblies.Count} assemblies.");
+ output.WriteLine();
+ foreach (var group in groups)
+ {
+ output.WriteLine($"{group.Assembly.ShortName} ({group.Assembly.FileName})");
+ output.Indent();
+ foreach (var unresolved in group.References)
+ {
+ output.WriteLine(unresolved.Reference.FullName);
+ output.Indent();
+ var log = new UnresolvedAssemblyNameReference(unresolved.Reference.FullName);
+ log.Messages.AddRange(unresolved.Messages.Select(m => (m.Kind, m.Message)));
+ AssemblyReferenceTreeNode.PrintAssemblyLoadLogMessages(output, log);
+ output.Unindent();
+ }
+ output.Unindent();
+ output.WriteLine();
+ }
+ return output;
+ }
+ }
+}
diff --git a/ILSpy/Dependencies/DependencyGraphControl.cs b/ILSpy/Dependencies/DependencyGraphControl.cs
new file mode 100644
index 00000000000..53a1acbe85f
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyGraphControl.cs
@@ -0,0 +1,330 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Collections.Generic;
+using System.Globalization;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+using ICSharpCode.ILSpyX.Dependencies;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+ ///
+ /// Draws an laid out by :
+ /// boxes for assemblies (bold border for roots, dashed red for unresolved references) and
+ /// curved arrows for references (orange when satisfied by a different version). The mouse
+ /// wheel zooms around the pointer, dragging pans, a click raises and
+ /// a double-click raises .
+ ///
+ public sealed class DependencyGraphControl : Control
+ {
+ const double FontSize = 12;
+ const double HorizontalPadding = 10;
+ const double MinScale = 0.05;
+ const double MaxScale = 4;
+ const double DragThreshold = 4;
+
+ static readonly Typeface NormalTypeface = new(FontFamily.Default);
+ static readonly Typeface BoldTypeface = new(FontFamily.Default, FontStyle.Normal, FontWeight.Bold);
+
+ AssemblyDependencyGraph? graph;
+ LayeredLayoutResult? layout;
+ double scale = 1;
+ Vector offset;
+ bool fitPending = true;
+ Point? pressPoint;
+ Vector pressOffset;
+ bool dragging;
+
+ public DependencyGraphControl()
+ {
+ ClipToBounds = true;
+ Focusable = true;
+ }
+
+ /// Raised with the node index when a node is clicked.
+ public event Action? NodeClicked;
+
+ /// Raised with the node index when a node is double-clicked.
+ public event Action? NodeDoubleClicked;
+
+ public AssemblyDependencyGraph? Graph {
+ get => graph;
+ set {
+ graph = value;
+ layout = null;
+ fitPending = true;
+ InvalidateVisual();
+ }
+ }
+
+ /// Index of the highlighted node, or -1.
+ public int SelectedIndex { get; set; } = -1;
+
+ /// The current layout (computed on first use).
+ public LayeredLayoutResult? Layout => EnsureLayout();
+
+ public double Scale => scale;
+
+ LayeredLayoutResult? EnsureLayout()
+ {
+ if (layout != null || graph == null)
+ return layout;
+ var widths = new double[graph.Nodes.Count];
+ foreach (var node in graph.Nodes)
+ {
+ var text = CreateLabel(node, Brushes.Black);
+ widths[node.Index] = Math.Ceiling(text.Width) + 2 * HorizontalPadding;
+ }
+ layout = LayeredLayout.Compute(graph.Nodes.Count, graph.EdgePairs, widths,
+ new LayeredLayoutOptions { NodeHeight = 28, LayerSpacing = 90, NodeSpacing = 14 });
+ return layout;
+ }
+
+ static string LabelOf(AssemblyDependencyNode node)
+ => node.Version != null ? $"{node.Name} {node.Version}" : node.Name;
+
+ static FormattedText CreateLabel(AssemblyDependencyNode node, IBrush brush)
+ => new(LabelOf(node), CultureInfo.CurrentUICulture, FlowDirection.LeftToRight,
+ node.IsRoot ? BoldTypeface : NormalTypeface, FontSize, brush);
+
+ /// Scales and centers the drawing so the whole graph fits the control.
+ public void ZoomToFit()
+ {
+ var l = EnsureLayout();
+ if (l == null || Bounds.Width <= 0 || Bounds.Height <= 0 || l.Width <= 0 || l.Height <= 0)
+ return;
+ scale = Math.Clamp(Math.Min(Bounds.Width / l.Width, Bounds.Height / l.Height), MinScale, 1);
+ offset = new Vector((Bounds.Width - l.Width * scale) / 2, (Bounds.Height - l.Height * scale) / 2);
+ fitPending = false;
+ InvalidateVisual();
+ }
+
+ /// Converts a point in control coordinates to layout coordinates.
+ public Point ToLayout(Point controlPoint) => new((controlPoint.X - offset.X) / scale, (controlPoint.Y - offset.Y) / scale);
+
+ /// Center of a node in control coordinates.
+ public Point GetNodeCenter(int index)
+ {
+ var l = EnsureLayout() ?? throw new InvalidOperationException("No graph.");
+ var r = l.Nodes[index];
+ return new Point((r.X + r.Width / 2) * scale + offset.X, r.CenterY * scale + offset.Y);
+ }
+
+ /// Index of the node under a point in control coordinates, or -1.
+ public int HitTestNode(Point controlPoint)
+ {
+ var l = EnsureLayout();
+ if (l == null)
+ return -1;
+ var p = ToLayout(controlPoint);
+ return l.HitTest(p.X, p.Y);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var result = base.ArrangeOverride(finalSize);
+ if (fitPending && finalSize.Width > 0 && finalSize.Height > 0)
+ {
+ // Bounds is updated after ArrangeOverride returns, so fit against the final size.
+ var l = EnsureLayout();
+ if (l != null && l.Width > 0 && l.Height > 0)
+ {
+ scale = Math.Clamp(Math.Min(finalSize.Width / l.Width, finalSize.Height / l.Height), MinScale, 1);
+ offset = new Vector((finalSize.Width - l.Width * scale) / 2, (finalSize.Height - l.Height * scale) / 2);
+ fitPending = false;
+ }
+ }
+ return result;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ // A transparent background makes the whole area hit-testable for panning and zooming.
+ context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
+ var l = EnsureLayout();
+ if (l == null || graph == null)
+ return;
+
+ bool dark = ActualThemeVariant == ThemeVariant.Dark;
+ IBrush text = dark ? Brushes.Gainsboro : Brushes.Black;
+ IBrush nodeFill = dark ? new SolidColorBrush(Color.FromRgb(0x2d, 0x33, 0x3b)) : new SolidColorBrush(Color.FromRgb(0xee, 0xf3, 0xfa));
+ IBrush nodeStroke = dark ? new SolidColorBrush(Color.FromRgb(0x7a, 0x9c, 0xc6)) : new SolidColorBrush(Color.FromRgb(0x3c, 0x6e, 0xb4));
+ IBrush unresolvedFill = dark ? new SolidColorBrush(Color.FromRgb(0x4a, 0x24, 0x24)) : new SolidColorBrush(Color.FromRgb(0xfd, 0xe2, 0xe2));
+ IBrush unresolvedStroke = new SolidColorBrush(Color.FromRgb(0xc0, 0x39, 0x2b));
+ IBrush edgeBrush = dark ? new SolidColorBrush(Color.FromRgb(0x8a, 0x8a, 0x8a)) : new SolidColorBrush(Color.FromRgb(0x70, 0x70, 0x70));
+ IBrush mismatchBrush = new SolidColorBrush(Color.FromRgb(0xe6, 0x7e, 0x22));
+ IBrush selectedStroke = new SolidColorBrush(Color.FromRgb(0xf1, 0xc4, 0x0f));
+
+ using (context.PushTransform(Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(offset)))
+ {
+ for (int i = 0; i < l.Edges.Count; i++)
+ {
+ var edge = l.Edges[i];
+ bool mismatch = graph.Edges[i].IsVersionMismatch;
+ DrawEdge(context, edge.Points, new Pen(mismatch ? mismatchBrush : edgeBrush, mismatch ? 1.6 : 1.1));
+ }
+ foreach (var node in graph.Nodes)
+ {
+ var r = l.Nodes[node.Index];
+ var rect = new Rect(r.X, r.Y, r.Width, r.Height);
+ var fill = node.IsResolved ? nodeFill : unresolvedFill;
+ Pen pen;
+ if (node.Index == SelectedIndex)
+ pen = new Pen(selectedStroke, 3);
+ else if (!node.IsResolved)
+ pen = new Pen(unresolvedStroke, 1.5, new DashStyle(new double[] { 4, 3 }, 0));
+ else
+ pen = new Pen(nodeStroke, node.IsRoot ? 2.5 : 1.2);
+ context.DrawRectangle(fill, pen, rect, 4, 4);
+ var label = CreateLabel(node, node.IsResolved ? text : unresolvedStroke);
+ context.DrawText(label, new Point(r.X + HorizontalPadding, r.CenterY - label.Height / 2));
+ }
+ }
+ }
+
+ // Edges between layers are drawn as cubic curves with horizontal tangents, straight runs
+ // through skipped layers as lines, and the end gets an arrow head.
+ static void DrawEdge(DrawingContext context, IReadOnlyList points, Pen pen)
+ {
+ if (points.Count < 2)
+ return;
+ var geometry = new StreamGeometry();
+ using (var g = geometry.Open())
+ {
+ g.BeginFigure(ToPoint(points[0]), false);
+ for (int i = 1; i < points.Count; i++)
+ {
+ var a = ToPoint(points[i - 1]);
+ var b = ToPoint(points[i]);
+ if (Math.Abs(a.Y - b.Y) < 0.5)
+ {
+ g.LineTo(b);
+ }
+ else
+ {
+ double dx = (b.X - a.X) / 2;
+ g.CubicBezierTo(new Point(a.X + dx, a.Y), new Point(b.X - dx, b.Y), b);
+ }
+ }
+ g.EndFigure(false);
+ }
+ context.DrawGeometry(null, pen, geometry);
+
+ var tip = ToPoint(points[^1]);
+ double direction = Math.Sign(points[^1].X - points[^2].X);
+ if (direction == 0)
+ direction = 1;
+ const double length = 8, half = 4;
+ var head = new StreamGeometry();
+ using (var g = head.Open())
+ {
+ g.BeginFigure(tip, true);
+ g.LineTo(new Point(tip.X - direction * length, tip.Y - half));
+ g.LineTo(new Point(tip.X - direction * length, tip.Y + half));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(pen.Brush, null, head);
+ }
+
+ static Point ToPoint(LayoutPoint p) => new(p.X, p.Y);
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ var position = e.GetPosition(this);
+ double factor = Math.Pow(1.15, e.Delta.Y);
+ double newScale = Math.Clamp(scale * factor, MinScale, MaxScale);
+ // Keep the layout point under the pointer fixed while zooming.
+ var anchor = ToLayout(position);
+ scale = newScale;
+ offset = new Vector(position.X - anchor.X * scale, position.Y - anchor.Y * scale);
+ fitPending = false;
+ InvalidateVisual();
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ var position = e.GetPosition(this);
+ if (e.ClickCount >= 2)
+ {
+ int index = HitTestNode(position);
+ pressPoint = null;
+ if (index >= 0)
+ {
+ NodeDoubleClicked?.Invoke(index);
+ e.Handled = true;
+ }
+ return;
+ }
+ pressPoint = position;
+ pressOffset = offset;
+ dragging = false;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ if (pressPoint is not { } start)
+ return;
+ var delta = e.GetPosition(this) - start;
+ if (!dragging && (Math.Abs(delta.X) > DragThreshold || Math.Abs(delta.Y) > DragThreshold))
+ dragging = true;
+ if (dragging)
+ {
+ offset = pressOffset + delta;
+ fitPending = false;
+ InvalidateVisual();
+ }
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (pressPoint is not { } start)
+ return;
+ pressPoint = null;
+ e.Pointer.Capture(null);
+ if (dragging)
+ {
+ dragging = false;
+ return;
+ }
+ int index = HitTestNode(start);
+ if (index >= 0)
+ {
+ SelectedIndex = index;
+ InvalidateVisual();
+ NodeClicked?.Invoke(index);
+ }
+ e.Handled = true;
+ }
+ }
+}
diff --git a/ILSpy/Dependencies/DependencyGraphView.cs b/ILSpy/Dependencies/DependencyGraphView.cs
new file mode 100644
index 00000000000..717dba88692
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyGraphView.cs
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+ ///