From 22651965dd0bbd56786ab4ab1d39d4200cf506ab Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 9 May 2026 00:29:52 +0200 Subject: [PATCH] Double-click metadata row opens its entity in the decompiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataGrid.DoubleTapped raises a RowActivated event on the metadata page model. The dock workspace subscribes during AttachCustomContent and resolves the row's metadataFile + Token to an IEntity via the type system, then selects the matching tree node — which triggers ShowSelectedNode → CreateTab → decompiler view, the same path a click in the assembly tree takes. Falls through silently for rows whose token doesn't resolve to an IEntity (heap rows, AssemblyRef, constants). Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Metadata/MetadataRowActivationTests.cs | 85 +++++++++++++++++++ ILSpy/Docking/DockWorkspace.cs | 55 +++++++++++- ILSpy/ViewModels/MetadataTablePageModel.cs | 10 +++ ILSpy/Views/MetadataTablePage.axaml.cs | 15 ++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 ILSpy.Tests/Metadata/MetadataRowActivationTests.cs diff --git a/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs new file mode 100644 index 000000000..9a324e27d --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs @@ -0,0 +1,85 @@ +// 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.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 MetadataRowActivationTests +{ + [AvaloniaTest] + public async Task Activating_A_TypeDef_Row_Selects_The_Matching_TypeTreeNode() + { + // Open the TypeDef table for CoreLib, pick the row whose Name == "Object", fire + // RowActivated, and confirm the assembly tree's TypeTreeNode for System.Object + // is now selected — that's the gateway to the decompiler view. + + 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(); + + var objectRow = tab.Items.Cast() + .First(e => e.Name == "Object" && e.Namespace == "System"); + + tab.RaiseRowActivated(objectRow); + + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode tn + && tn.Member is global::ICSharpCode.Decompiler.TypeSystem.ITypeDefinition td + && td.FullName == "System.Object"); + (((TypeTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member + as global::ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)! + .FullName.Should().Be("System.Object"); + } +} + +internal static class MetadataRowActivationTestExtensions +{ + public static void RaiseRowActivated(this MetadataTablePageModel page, object row) => + typeof(MetadataTablePageModel) + .GetMethod("RaiseRowActivated", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(page, [row]); +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 03c213d1f..f44221085 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -32,6 +32,8 @@ using Dock.Model.Core; using Dock.Model.Core.Events; using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; using ILSpy.AssemblyTree; @@ -306,11 +308,18 @@ namespace ILSpy.Docking void AttachCustomContent(ContentTabPage main, TabPageModel newContent) { // Detach navigation handlers from the outgoing content; subscribe on the - // incoming one so token clicks route through OnMetadataCellClicked. + // incoming one so token clicks route through OnMetadataCellClicked and + // row activation routes through OnMetadataRowActivated. if (main.Content is MetadataTablePageModel oldMeta) + { oldMeta.NavigateToCellRequested -= OnMetadataCellClicked; + oldMeta.RowActivated -= OnMetadataRowActivated; + } if (newContent is MetadataTablePageModel newMeta) + { newMeta.NavigateToCellRequested += OnMetadataCellClicked; + newMeta.RowActivated += OnMetadataRowActivated; + } main.Content = newContent; } @@ -328,6 +337,50 @@ namespace ILSpy.Docking NavigateToToken(new MetadataTokenReference(metadataFile, MetadataTokens.EntityHandle(token))); } + internal void OnMetadataRowActivated(object row) + { + // Row double-click: try to resolve the row's Token + metadataFile to a real + // IEntity via the type system, then select the matching tree node. That + // triggers ShowSelectedNode → CreateTab → decompiler view, the same path a + // click in the assembly tree takes. + var fileField = row.GetType().GetField("metadataFile", BindingFlags.Instance | BindingFlags.NonPublic); + if (fileField?.GetValue(row) is not MetadataFile metadataFile) + return; + var tokenProp = row.GetType().GetProperty("Token"); + if (tokenProp?.GetValue(row) is not int token || token == 0) + return; + var handle = MetadataTokens.EntityHandle(token); + if (handle.IsNil) + return; + + var assemblies = assemblyTreeModel.AssemblyList?.GetAssemblies(); + if (assemblies is null) + return; + LoadedAssembly? owningAssembly = null; + foreach (var a in assemblies) + { + if (ReferenceEquals(a.GetMetadataFileOrNull(), metadataFile)) + { + owningAssembly = a; + break; + } + } + if (owningAssembly is null) + return; + var ts = owningAssembly.GetTypeSystemOrNull(); + if (ts?.MainModule is not MetadataModule metadataModule) + return; + IEntity? entity; + try + { entity = metadataModule.ResolveEntity(handle); } + catch { return; } + if (entity is null) + return; + var node = assemblyTreeModel.FindTreeNode(entity); + if (node is not null) + assemblyTreeModel.SelectedItem = node; + } + public void NavigateToToken(MetadataTokenReference reference) { if (reference.Handle.IsNil) diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index d45341fa9..56db9d884 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -103,6 +103,16 @@ namespace ILSpy.ViewModels internal void RaiseNavigateToCell(object row, string columnName) => NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName)); + /// + /// Raised when the user double-clicks (or otherwise activates) a metadata grid + /// row. The dock workspace resolves the row's metadataFile + Token + /// to an and selects the + /// matching tree node, which opens the entity in the decompiler view. + /// + public event Action? RowActivated; + + internal void RaiseRowActivated(object row) => RowActivated?.Invoke(row); + static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = new(); static readonly ConcurrentDictionary regexCache = new(); diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 73c3a029b..500a2e873 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -57,6 +57,21 @@ namespace ILSpy.Views AddHandler(PointerMovedEvent, OnPointerMovedOverGrid); AddHandler(KeyDownEvent, OnKeyDown); AttachContextMenu(TryGetContextMenuEntries()); + var grid = this.FindControl("Grid"); + if (grid is not null) + grid.DoubleTapped += OnGridDoubleTapped; + } + + void OnGridDoubleTapped(object? sender, global::Avalonia.Input.TappedEventArgs e) + { + // Dispatch row-activation through the page model. The dock workspace's + // subscriber resolves the row's metadataFile + Token to an IEntity and selects + // the matching tree node; that opens the entity in the decompiler view. + if (DataContext is not MetadataTablePageModel page) + return; + var grid = this.FindControl("Grid"); + if (grid?.SelectedItem is { } row) + page.RaiseRowActivated(row); } void OnKeyDown(object? sender, KeyEventArgs e)