Browse Source

"Show in metadata" context entry on decompiler hyperlinks

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
67e5dc1c4d
  1. 103
      ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs
  2. 64
      ILSpy/Commands/ShowInMetadataContextMenuEntry.cs
  3. 2
      ILSpy/Docking/DockWorkspace.cs
  4. 69
      ILSpy/TextView/DecompilerTextView.axaml.cs

103
ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs

@ -0,0 +1,103 @@ @@ -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<MainWindow>();
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<AssemblyTreeNode>(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();
}
}

64
ILSpy/Commands/ShowInMetadataContextMenuEntry.cs

@ -0,0 +1,64 @@ @@ -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
{
/// <summary>
/// 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
/// <see cref="IEntity"/> 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.
/// </summary>
[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<DockWorkspace>().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);
}
}
}

2
ILSpy/Docking/DockWorkspace.cs

@ -328,7 +328,7 @@ namespace ILSpy.Docking @@ -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;

69
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -42,6 +42,9 @@ using ICSharpCode.Decompiler.Metadata; @@ -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 @@ -69,6 +72,8 @@ namespace ILSpy.TextView
RichTextColorizer? activeColorizer;
FoldingManager? activeFoldingManager;
ReferenceSegment? lastTooltipSegment;
ReferenceSegment? lastRightClickedSegment;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
readonly Popup richPopup;
double distanceToPopupLimit;
@ -117,6 +122,15 @@ namespace ILSpy.TextView @@ -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 @@ -140,6 +154,61 @@ namespace ILSpy.TextView
// need to be in any visual tree of ours.
}
static IReadOnlyList<IContextMenuEntryExport> TryGetContextMenuEntries()
{
try
{ return AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries; }
catch { return Array.Empty<IContextMenuEntryExport>(); }
}
/// <summary>
/// Replaces the active context-menu entries. Tests bypass the MEF registry by
/// calling this directly with stub entries.
/// </summary>
internal void AttachContextMenu(IReadOnlyList<IContextMenuEntryExport> 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<Control>().ToArray())
{
built.Items.Remove(item);
menu.Items.Add(item);
}
}
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);

Loading…
Cancel
Save