Browse Source

Go-to-token context menu on metadata grid

Right-click on a [Kind=Token] cell now exposes a "Go to token" menu item
that reuses the same NavigateToCellRequested path as the hyperlink-style
left-click. Visibility is gated on the click landing on a token-kind
column, so plain columns and non-metadata grids stay clean.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
bd2d14454b
  1. 119
      ILSpy.Tests/Metadata/GoToTokenContextMenuTests.cs
  2. 75
      ILSpy/Commands/GoToTokenContextMenuEntry.cs
  3. 51
      ILSpy/Views/MetadataTablePage.axaml.cs

119
ILSpy.Tests/Metadata/GoToTokenContextMenuTests.cs

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.Commands;
using ILSpy.Metadata;
using ILSpy.ViewModels;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Metadata;
[TestFixture]
public class GoToTokenContextMenuTests
{
sealed class Row
{
public int RID { get; set; }
[ColumnInfo("X8", Kind = ColumnKind.Token)]
public int Method { get; set; }
public string Name { get; set; } = "";
}
static DataGridCell MakeCell(DataGridColumn column, object dataContext)
{
// DataGridCell.OwningColumn has an internal setter; the registered DirectProperty is
// public, so we route through SetValue to wire the synthetic cell up the same way the
// DataGrid does at runtime.
var cell = new DataGridCell { DataContext = dataContext };
cell.SetValue(DataGridCell.OwningColumnProperty, column);
return cell;
}
[AvaloniaTest]
public void IsVisible_True_When_Right_Click_Lands_On_A_Token_Kind_Cell()
{
// The entry should only surface when the user clicked a hyperlink-style cell —
// otherwise "Go to token" would be a no-op or, worse, dispatch to the wrong target.
var entry = new GoToTokenContextMenuEntry();
var col = new DataGridTemplateColumn { Header = "Method" };
var cell = MakeCell(col, new Row { Method = 0x06000001 });
var ctx = new TextViewContext { DataGrid = new DataGrid(), OriginalSource = cell };
entry.IsVisible(ctx).Should().BeTrue();
}
[AvaloniaTest]
public void IsVisible_False_When_Cell_Is_On_A_Plain_Column()
{
// Name is annotated-without-Kind; right-clicking it must not show the entry.
var entry = new GoToTokenContextMenuEntry();
var col = new DataGridTextColumn { Header = "Name" };
var cell = MakeCell(col, new Row { Name = "X" });
var ctx = new TextViewContext { DataGrid = new DataGrid(), OriginalSource = cell };
entry.IsVisible(ctx).Should().BeFalse();
}
[AvaloniaTest]
public void IsVisible_False_When_Context_Has_No_DataGrid()
{
// On the assembly tree (TreeGrid), the metadata-only entry must stay hidden — the
// DataGrid field is the discriminator between "right-click on a metadata cell" and
// "right-click anywhere else".
var entry = new GoToTokenContextMenuEntry();
var col = new DataGridTemplateColumn { Header = "Method" };
var cell = MakeCell(col, new Row { Method = 1 });
var ctx = new TextViewContext { OriginalSource = cell };
entry.IsVisible(ctx).Should().BeFalse();
}
[AvaloniaTest]
public void Execute_Raises_NavigateToCellRequested_On_The_Page_Model()
{
// Reuses the existing token-cell navigation path: the same code path that fires when
// the user left-clicks a hyperlink cell, just triggered through the menu instead.
var page = new MetadataTablePageModel();
object? capturedRow = null;
string? capturedColumn = null;
page.NavigateToCellRequested += args => {
capturedRow = args.Row;
capturedColumn = args.ColumnName;
};
var grid = new DataGrid { DataContext = page };
var col = new DataGridTemplateColumn { Header = "Method" };
var row = new Row { Method = 0x06000001 };
var cell = MakeCell(col, row);
var ctx = new TextViewContext { DataGrid = grid, OriginalSource = cell };
new GoToTokenContextMenuEntry().Execute(ctx);
capturedRow.Should().BeSameAs(row);
capturedColumn.Should().Be("Method");
}
}

75
ILSpy/Commands/GoToTokenContextMenuEntry.cs

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
// 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 System.Reflection;
using Avalonia.Controls;
using ICSharpCode.ILSpy.Properties;
using ILSpy.Metadata;
using ILSpy.ViewModels;
namespace ILSpy.Commands
{
/// <summary>
/// Right-click → "Go to token" on a metadata-table cell. Visible only when the click
/// landed on a column annotated <c>[ColumnInfo(Kind = Token)]</c>; firing it routes
/// through the page model's <c>NavigateToCellRequested</c> event so the dock workspace
/// resolves the (row, column) pair to the target table row — the same path a left-click
/// on the hyperlink-styled cell takes.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.GoToToken), Order = 100, InputGestureText = "Ctrl+G")]
[Shared]
public sealed class GoToTokenContextMenuEntry : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => ResolveCell(context) is not null;
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context)
{
if (ResolveCell(context) is not (DataGridCell cell, string columnName))
return;
if (context.DataGrid?.DataContext is not MetadataTablePageModel page)
return;
page.RaiseNavigateToCell(cell.DataContext!, columnName);
}
static (DataGridCell Cell, string ColumnName)? ResolveCell(TextViewContext context)
{
if (context.DataGrid is null)
return null;
if (context.OriginalSource is not DataGridCell cell)
return null;
if (cell.OwningColumn is null)
return null;
var columnName = cell.OwningColumn.Header?.ToString();
if (string.IsNullOrEmpty(columnName))
return null;
var row = cell.DataContext;
if (row is null)
return null;
var prop = row.GetType().GetProperty(columnName, BindingFlags.Public | BindingFlags.Instance);
if (prop?.GetCustomAttribute<ColumnInfoAttribute>()?.Kind != ColumnKind.Token)
return null;
return (cell, columnName);
}
}
}

51
ILSpy/Views/MetadataTablePage.axaml.cs

@ -17,7 +17,9 @@ @@ -17,7 +17,9 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Collections;
@ -26,6 +28,7 @@ using Avalonia.Input; @@ -26,6 +28,7 @@ using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using ILSpy.AppEnv;
using ILSpy.Metadata;
using ILSpy.ViewModels;
@ -48,6 +51,54 @@ namespace ILSpy.Views @@ -48,6 +51,54 @@ namespace ILSpy.Views
InitializeComponent();
DataContextChanged += (_, _) => RebindModel();
AddHandler(PointerMovedEvent, OnPointerMovedOverGrid);
AttachContextMenu(TryGetContextMenuEntries());
}
static IReadOnlyList<IContextMenuEntryExport> TryGetContextMenuEntries()
{
try
{ return AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries; }
catch { return Array.Empty<IContextMenuEntryExport>(); }
}
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
/// <summary>
/// Replaces the active context-menu entries. Tests may call this directly to inject
/// stub entries; at runtime construction registers the MEF-discovered set.
/// </summary>
internal void AttachContextMenu(IReadOnlyList<IContextMenuEntryExport> entries)
{
contextMenuEntries = entries;
var grid = this.FindControl<DataGrid>("Grid");
if (grid == null)
return;
var menu = new ContextMenu();
menu.Opening += OnContextMenuOpening;
grid.ContextMenu = menu;
}
void OnContextMenuOpening(object? sender, CancelEventArgs e)
{
if (sender is not ContextMenu menu)
return;
var grid = this.FindControl<DataGrid>("Grid");
var context = new TextViewContext {
DataGrid = grid,
OriginalSource = hoveredCell,
};
var built = ContextMenuProvider.Build(contextMenuEntries, context);
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);
}
}
void InitializeComponent() => AvaloniaXamlLoader.Load(this);

Loading…
Cancel
Save