From 67e5dc1c4df303afe08df2f9f85a1b31b22c05ec Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 8 May 2026 16:01:14 +0200 Subject: [PATCH] "Show in metadata" context entry on decompiler hyperlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking an entity hyperlink in the decompiler view now offers "Go to token" — when the click lands on a reference whose payload is an IEntity backed by a real metadata module, the entry routes through the dock workspace to the matching CLI metadata table at the entity's row. The decompiler text view also gets a context-menu host so other [ExportContextMenuEntry] contributors that target a TextView surface become reachable here too. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../ShowInMetadataContextMenuTests.cs | 103 ++++++++++++++++++ .../ShowInMetadataContextMenuEntry.cs | 64 +++++++++++ ILSpy/Docking/DockWorkspace.cs | 2 +- ILSpy/TextView/DecompilerTextView.axaml.cs | 69 ++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs create mode 100644 ILSpy/Commands/ShowInMetadataContextMenuEntry.cs diff --git a/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs b/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs new file mode 100644 index 000000000..dc6f19f9a --- /dev/null +++ b/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Reflection.Metadata.Ecma335; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.Metadata; +using ILSpy.Metadata.CorTables; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +[TestFixture] +public class ShowInMetadataContextMenuTests +{ + [AvaloniaTest] + public async Task Right_Click_On_An_Entity_Reference_In_The_Decompiler_Routes_To_Its_Metadata_Row() + { + // End-to-end: open a CoreLib type so the decompiler view fills with entity + // hyperlinks; synthesize a TextViewContext pointing at one of those references; + // verify the entry's IsVisible/Execute drives the dock workspace into the right + // metadata table for the entity's MetadataToken.kind. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName); + + // Resolve System.Object's IEntity via the type system, since the entry only acts + // on IEntity-bearing references. + await assemblyNode.LoadedAssembly.GetLoadResultAsync(); + var ts = assemblyNode.LoadedAssembly.GetTypeSystemOrNull()!; + var entity = ts.MainModule.TypeDefinitions.Single(t => t.FullName == "System.Object"); + entity.MetadataToken.IsNil.Should().BeFalse( + "System.Object must have a real metadata token in CoreLib"); + + var ctx = new TextViewContext { + TextView = new DecompilerTextView(), + Reference = new ReferenceSegment { Reference = entity }, + }; + var entry = new ShowInMetadataContextMenuEntry(); + entry.IsVisible(ctx).Should().BeTrue( + "the entry must surface for entity references in the decompiler view"); + + entry.Execute(ctx); + + var expectedTable = (TableIndex)(int)entity.MetadataToken.Kind; + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m + && m.Kind == expectedTable); + ((MetadataTableTreeNode)vm.AssemblyTreeModel.SelectedItem!).Kind.Should().Be(expectedTable); + } + + [AvaloniaTest] + public void IsVisible_False_When_There_Is_No_TextView_Or_Entity_Reference() + { + // The entry is decompiler-only. A right-click in a context that has no TextView, + // or whose Reference is null / not an IEntity (e.g. an opcode link), must not + // surface the entry — otherwise it would fire an unrelated NavigateToToken. + var entry = new ShowInMetadataContextMenuEntry(); + + entry.IsVisible(new TextViewContext()).Should().BeFalse(); + entry.IsVisible(new TextViewContext { TextView = new DecompilerTextView() }) + .Should().BeFalse("a TextView without a Reference is just empty whitespace"); + entry.IsVisible(new TextViewContext { + TextView = new DecompilerTextView(), + Reference = new ReferenceSegment { Reference = "not-an-entity" }, + }).Should().BeFalse(); + } +} diff --git a/ILSpy/Commands/ShowInMetadataContextMenuEntry.cs b/ILSpy/Commands/ShowInMetadataContextMenuEntry.cs new file mode 100644 index 000000000..bdd7db167 --- /dev/null +++ b/ILSpy/Commands/ShowInMetadataContextMenuEntry.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Composition; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Properties; + +using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.Metadata; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Show in metadata" on an entity hyperlink in the decompiler view. + /// Visible whenever the click landed on a reference whose payload is an + /// backed by a real metadata file; firing it routes through the + /// dock workspace to open the matching CLI metadata table at the entity's row. + /// + [ExportContextMenuEntry(Header = nameof(Resources.GoToToken), Category = "Metadata", Order = 200)] + [Shared] + public sealed class ShowInMetadataContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => Resolve(context) is not null; + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) + { + if (Resolve(context) is not { } target) + return; + AppComposition.Current.GetExport().NavigateToToken(target); + } + + static MetadataTokenReference? Resolve(TextViewContext context) + { + if (context.TextView is null) + return null; + if (context.Reference?.Reference is not IEntity entity) + return null; + if (entity.ParentModule?.MetadataFile is not { } module) + return null; + if (entity.MetadataToken.IsNil) + return null; + return new MetadataTokenReference(module, entity.MetadataToken); + } + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 0e452e2a7..03c213d1f 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -328,7 +328,7 @@ namespace ILSpy.Docking NavigateToToken(new MetadataTokenReference(metadataFile, MetadataTokens.EntityHandle(token))); } - void NavigateToToken(MetadataTokenReference reference) + public void NavigateToToken(MetadataTokenReference reference) { if (reference.Handle.IsNil) return; diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 745e849a6..7fc164948 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -42,6 +42,9 @@ using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; +using ILSpy; +using ILSpy.AppEnv; + namespace ILSpy.TextView { public partial class DecompilerTextView : UserControl @@ -69,6 +72,8 @@ namespace ILSpy.TextView RichTextColorizer? activeColorizer; FoldingManager? activeFoldingManager; ReferenceSegment? lastTooltipSegment; + ReferenceSegment? lastRightClickedSegment; + IReadOnlyList contextMenuEntries = Array.Empty(); readonly Popup richPopup; double distanceToPopupLimit; @@ -117,6 +122,15 @@ namespace ILSpy.TextView Editor.TextArea.TextView.PointerMoved += OnTextViewPointerMoved; Editor.TextArea.TextView.PointerExited += OnTextViewPointerExited; + // Context menu — captures the segment under the pointer at right-click time so + // entries like "Show in metadata" can dispatch through the same Reference field + // the assembly-tree's right-click already populates. + Editor.TextArea.TextView.AddHandler(InputElement.PointerPressedEvent, + OnTextViewPointerPressedForContextMenu, + RoutingStrategies.Tunnel, + handledEventsToo: true); + AttachContextMenu(TryGetContextMenuEntries()); + // AvaloniaEdit's hyperlinks raise OpenUriEvent (bubbling); the default class handler // on Window passes the URI to Process.Start. Intercept first so internal schemes // (the About page's "resource:" URIs) route through the tab model instead. @@ -140,6 +154,61 @@ namespace ILSpy.TextView // need to be in any visual tree of ours. } + static IReadOnlyList TryGetContextMenuEntries() + { + try + { return AppComposition.Current.GetExport().Entries; } + catch { return Array.Empty(); } + } + + /// + /// Replaces the active context-menu entries. Tests bypass the MEF registry by + /// calling this directly with stub entries. + /// + internal void AttachContextMenu(IReadOnlyList entries) + { + contextMenuEntries = entries; + var menu = new ContextMenu(); + menu.Opening += OnContextMenuOpening; + Editor.TextArea.TextView.ContextMenu = menu; + } + + void OnTextViewPointerPressedForContextMenu(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(Editor.TextArea.TextView).Properties.IsRightButtonPressed) + return; + var pos = GetPositionFromPointer(e); + if (pos == null || DataContext is not DecompilerTabPageModel model || model.References == null) + { + lastRightClickedSegment = null; + return; + } + var offset = Editor.Document.GetOffset(pos.Value.Line, pos.Value.Column); + lastRightClickedSegment = model.References.FindSegmentsContaining(offset).FirstOrDefault(); + } + + void OnContextMenuOpening(object? sender, CancelEventArgs e) + { + if (sender is not ContextMenu menu) + return; + var ctx = new TextViewContext { + TextView = this, + Reference = lastRightClickedSegment, + }; + var built = ContextMenuProvider.Build(contextMenuEntries, ctx); + if (built == null) + { + e.Cancel = true; + return; + } + menu.Items.Clear(); + foreach (var item in built.Items.Cast().ToArray()) + { + built.Items.Remove(item); + menu.Items.Add(item); + } + } + protected override void OnDataContextChanged(System.EventArgs e) { base.OnDataContextChanged(e);