Browse Source

Token cell click dispatches to target table row

Adds MetadataTokenReference(MetadataFile, Handle). DockWorkspace
subscribes to a metadata page's NavigateToCellRequested when its
Content is attached; the click handler reads the row's MetadataFile
and the column's int token via reflection, walks to the matching
MetadataTableTreeNode under the assembly's Tables container (mapping
HandleKind → TableIndex by value), selects it, and seeds ScrollToRow
on the freshly-bound MetadataTablePageModel. The view's ApplyScrollTarget
already honours ScrollToRow by scrolling + selecting the row.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
71b1b3ab3b
  1. 101
      ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs
  2. 87
      ILSpy/Docking/DockWorkspace.cs
  3. 32
      ILSpy/Metadata/MetadataTokenReference.cs

101
ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs

@ -0,0 +1,101 @@ @@ -0,0 +1,101 @@
// 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 ILSpy.AppEnv;
using ILSpy.Metadata;
using ILSpy.Metadata.CorTables;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Metadata;
[TestFixture]
public class MetadataTokenNavigationTests
{
[AvaloniaTest]
public async Task Clicking_A_Token_Cell_In_TypeDef_Navigates_To_The_Referenced_Table_Row()
{
// Each TypeDef row carries a BaseType column annotated [Kind=Token]. Clicking it
// must dispatch through the docking host to the TypeRef / TypeDef / TypeSpec table
// the token belongs to and scroll the receiving grid to the referenced row.
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);
assemblyNode.EnsureLazyChildren();
var metadataNode = assemblyNode.Children.OfType<MetadataTreeNode>().Single();
metadataNode.EnsureLazyChildren();
var tablesNode = metadataNode.Children.OfType<MetadataTablesTreeNode>().Single();
tablesNode.EnsureLazyChildren();
var typeDefNode = tablesNode.Children.OfType<TypeDefTableTreeNode>().Single();
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
// Pick a row whose BaseType is non-zero (skip <Module>) and resolve the expected
// target table from the handle's runtime kind.
var rowWithBase = tab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
.First(e => e.BaseType != 0);
var expectedTableIndex = (TableIndex)(int)MetadataTokens.EntityHandle(rowWithBase.BaseType).Kind;
var expectedRowNumber = MetadataTokens.GetRowNumber(MetadataTokens.EntityHandle(rowWithBase.BaseType));
// Act — fire the navigate-to-cell event the way the hyperlink button does.
tab.RaiseNavigateToCell(rowWithBase, nameof(rowWithBase.BaseType));
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTableIndex);
// Assert — host swapped the tree selection + Content to the table matching the
// handle's kind. The actual scroll-into-view runs through MetadataTablePage's
// ApplyScrollTarget, which clears ScrollToRow synchronously after handling — so
// we don't observe the row index here, just that the selection is the right table
// and a fresh metadata page is showing.
var factory = (global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory;
var landed = (MetadataTablePageModel)factory.MainTab!.Content!;
landed.Title.Should().StartWith(expectedTableIndex.ToString());
landed.Items.Should().HaveCountGreaterThan((int)(expectedRowNumber - 1),
"the target row index must be in range for the destination table");
}
}
internal static class MetadataTokenNavigationTestExtensions
{
public static void RaiseNavigateToCell(this MetadataTablePageModel page, object row, string columnName)
{
// MetadataTablePageModel.RaiseNavigateToCell is internal; tests in this assembly
// can call it because [InternalsVisibleTo] is wired in the project file.
typeof(MetadataTablePageModel)
.GetMethod("RaiseNavigateToCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.Invoke(page, [row, columnName]);
}
}

87
ILSpy/Docking/DockWorkspace.cs

@ -21,6 +21,9 @@ using System.Collections.Generic; @@ -21,6 +21,9 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Composition;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using CommunityToolkit.Mvvm.Input;
@ -28,11 +31,13 @@ using Dock.Model.Controls; @@ -28,11 +31,13 @@ using Dock.Model.Controls;
using Dock.Model.Core;
using Dock.Model.Core.Events;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.AssemblyTree;
using ILSpy.Commands;
using ILSpy.Languages;
using ILSpy.Metadata;
using ILSpy.Navigation;
using ILSpy.TextView;
using ILSpy.TreeNodes;
@ -276,7 +281,7 @@ namespace ILSpy.Docking @@ -276,7 +281,7 @@ namespace ILSpy.Docking
if (main.Content?.GetType() == customContent.GetType())
CopyContentState(customContent, main.Content);
else
main.Content = customContent;
AttachCustomContent(main, customContent);
return;
}
@ -286,6 +291,86 @@ namespace ILSpy.Docking @@ -286,6 +291,86 @@ namespace ILSpy.Docking
decTab.CurrentNodes = nodes;
}
void AttachCustomContent(ContentTabPage main, TabPageModel newContent)
{
// Detach navigation handlers from the outgoing content; subscribe on the
// incoming one so token clicks route through OnMetadataCellClicked.
if (main.Content is MetadataTablePageModel oldMeta)
oldMeta.NavigateToCellRequested -= OnMetadataCellClicked;
if (newContent is MetadataTablePageModel newMeta)
newMeta.NavigateToCellRequested += OnMetadataCellClicked;
main.Content = newContent;
}
void OnMetadataCellClicked(MetadataCellNavigationEventArgs e)
{
// (row, columnName) → metadata token + module → matching table tree node + row index.
// Resolved via reflection because each entry struct buries its MetadataFile in a
// private readonly field with the same name across all 43 typed table viewmodels.
var prop = e.Row.GetType().GetProperty(e.ColumnName);
if (prop?.GetValue(e.Row) is not int token || token == 0)
return;
var fileField = e.Row.GetType().GetField("metadataFile", BindingFlags.Instance | BindingFlags.NonPublic);
if (fileField?.GetValue(e.Row) is not MetadataFile metadataFile)
return;
NavigateToToken(new MetadataTokenReference(metadataFile, MetadataTokens.EntityHandle(token)));
}
void NavigateToToken(MetadataTokenReference reference)
{
if (reference.Handle.IsNil)
return;
// Find the MetadataTreeNode for the referenced module, drill down to its
// Tables container, and pick the table whose Kind matches the handle's runtime
// kind — HandleKind values for table-backed entities double as TableIndex.
var assemblies = assemblyTreeModel.AssemblyList?.GetAssemblies();
if (assemblies == null)
return;
AssemblyTreeNode? targetAssembly = null;
if (assemblyTreeModel.Root != null)
{
foreach (var child in assemblyTreeModel.Root.Children.OfType<AssemblyTreeNode>())
{
if (ReferenceEquals(child.LoadedAssembly.GetMetadataFileOrNull(), reference.MetadataFile))
{
targetAssembly = child;
break;
}
}
}
if (targetAssembly == null)
return;
targetAssembly.EnsureLazyChildren();
var metaNode = targetAssembly.Children.OfType<MetadataTreeNode>().FirstOrDefault();
if (metaNode == null)
return;
metaNode.EnsureLazyChildren();
var tablesNode = metaNode.Children.OfType<MetadataTablesTreeNode>().FirstOrDefault();
if (tablesNode == null)
return;
tablesNode.EnsureLazyChildren();
var tableIndex = (TableIndex)(int)reference.Handle.Kind;
var tableNode = tablesNode.Children.OfType<MetadataTableTreeNode>()
.FirstOrDefault(t => t.Kind == tableIndex);
if (tableNode == null)
return;
suppressHistoryRecording = true;
try
{
assemblyTreeModel.SelectedItem = tableNode;
}
finally
{
suppressHistoryRecording = false;
}
// ShowSelectedNode just settled MainTab.Content to the table's MetadataTablePageModel.
// Set ScrollToRow to the handle's row number (1-based → 0-based).
if (factory.MainTab?.Content is MetadataTablePageModel pm)
pm.ScrollToRow = MetadataTokens.GetRowNumber((EntityHandle)reference.Handle) - 1;
}
DecompilerTabPageModel CreateDecompilerContent()
{
var tab = new DecompilerTabPageModel { Title = "(no selection)" };

32
ILSpy/Metadata/MetadataTokenReference.cs

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
// 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.Reflection.Metadata;
using ICSharpCode.Decompiler.Metadata;
namespace ILSpy.Metadata
{
/// <summary>
/// A click target carrying a metadata token within a specific module. Emitted when the
/// user clicks a hyperlink-styled token cell in a metadata-grid view; the docking host
/// resolves it to the matching <see cref="MetadataTableTreeNode"/> and scrolls that
/// table's grid to the referenced row.
/// </summary>
public sealed record MetadataTokenReference(MetadataFile MetadataFile, Handle Handle);
}
Loading…
Cancel
Save