diff --git a/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs b/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs new file mode 100644 index 000000000..b9df2a0fc --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs @@ -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(); + 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); + assemblyNode.EnsureLazyChildren(); + var metadataNode = assemblyNode.Children.OfType().Single(); + metadataNode.EnsureLazyChildren(); + var tablesNode = metadataNode.Children.OfType().Single(); + tablesNode.EnsureLazyChildren(); + var typeDefNode = tablesNode.Children.OfType().Single(); + + vm.AssemblyTreeModel.SelectNode(typeDefNode); + var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + + // Pick a row whose BaseType is non-zero (skip ) and resolve the expected + // target table from the handle's runtime kind. + var rowWithBase = tab.Items.Cast() + .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]); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 8fc2d268c..94eab4b6e 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -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; 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 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 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()) + { + if (ReferenceEquals(child.LoadedAssembly.GetMetadataFileOrNull(), reference.MetadataFile)) + { + targetAssembly = child; + break; + } + } + } + if (targetAssembly == null) + return; + targetAssembly.EnsureLazyChildren(); + var metaNode = targetAssembly.Children.OfType().FirstOrDefault(); + if (metaNode == null) + return; + metaNode.EnsureLazyChildren(); + var tablesNode = metaNode.Children.OfType().FirstOrDefault(); + if (tablesNode == null) + return; + tablesNode.EnsureLazyChildren(); + var tableIndex = (TableIndex)(int)reference.Handle.Kind; + var tableNode = tablesNode.Children.OfType() + .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)" }; diff --git a/ILSpy/Metadata/MetadataTokenReference.cs b/ILSpy/Metadata/MetadataTokenReference.cs new file mode 100644 index 000000000..f76509b1c --- /dev/null +++ b/ILSpy/Metadata/MetadataTokenReference.cs @@ -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 +{ + /// + /// 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 and scrolls that + /// table's grid to the referenced row. + /// + public sealed record MetadataTokenReference(MetadataFile MetadataFile, Handle Handle); +}