From 834f2638d69d8a995d27402497786b16d37b9202 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 9 May 2026 09:22:50 +0200 Subject: [PATCH] Open metadata row in new tab via Shift+dbl-click or menu Extends the double-click-to-decompile path with a new-tab variant. RaiseRowActivated now carries an OpenInNewTab flag; DockWorkspace branches on it to either reuse the active tab (existing tree-selection path) or spawn a fresh DecompilerTabPageModel. The view's OnGridDoubleTapped reads KeyModifiers.Shift; a new DecompileMetadataRowInNewTabCommand exports the same gesture as a context-menu entry. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../ContextMenus/DecompileInNewViewTests.cs | 7 +- .../Metadata/MetadataRowActivationTests.cs | 56 ++++++++++++- .../DecompileMetadataRowInNewTabCommand.cs | 81 +++++++++++++++++++ ILSpy/Docking/DockWorkspace.cs | 53 ++++++++---- ILSpy/ViewModels/MetadataTablePageModel.cs | 15 +++- ILSpy/Views/MetadataTablePage.axaml.cs | 11 ++- 6 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index 6040f866d..9fef17dd0 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -28,6 +28,7 @@ using ICSharpCode.ILSpyX.TreeView; using ILSpy; using ILSpy.AppEnv; +using ILSpy.Commands; using ILSpy.Docking; using ILSpy.TextView; using ILSpy.TreeNodes; @@ -70,7 +71,8 @@ public class DecompileInNewViewTests await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); var registry = AppComposition.Current.GetExport(); var entry = registry.Entries - .Single(e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel)) + .Single(e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel) + && e.Value is DecompileInNewViewCommand) .Value; var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); @@ -108,7 +110,8 @@ public class DecompileInNewViewTests var registry = AppComposition.Current.GetExport(); var entry = registry.Entries - .Single(e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel)) + .Single(e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel) + && e.Value is DecompileInNewViewCommand) .Value; var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; diff --git a/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs index 9a324e27d..bedde39b7 100644 --- a/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs @@ -23,7 +23,10 @@ using Avalonia.Headless.NUnit; using AwesomeAssertions; +using ILSpy; using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.Docking; using ILSpy.Metadata; using ILSpy.Metadata.CorTables; using ILSpy.TreeNodes; @@ -74,12 +77,61 @@ public class MetadataRowActivationTests as global::ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)! .FullName.Should().Be("System.Object"); } + + [AvaloniaTest] + public async Task Activating_A_Row_With_OpenInNewTab_Spawns_A_Fresh_Decompiler_Tab() + { + // Shift+double-click on a metadata row should NOT replace the active tab — it should + // open a brand-new decompiler tab containing the row's entity. The single-tab-reuse + // path stays put. + 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 metadataTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + var objectRow = metadataTab.Items.Cast() + .First(e => e.Name == "Object" && e.Namespace == "System"); + + var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + var initialCount = documents.VisibleDockables?.Count ?? 0; + + metadataTab.RaiseRowActivated(objectRow, openInNewTab: true); + + await Waiters.WaitForAsync( + () => (documents.VisibleDockables?.Count ?? 0) > initialCount); + var decompiledTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + decompiledTab.Text.Should().Contain("System.Object"); + } + + [AvaloniaTest] + public async Task DecompileMetadataRowInNewTab_Entry_Is_Registered() + { + // Right-click on a metadata-grid row shows a "Decompile to new tab" entry alongside + // Go-to-token / Show-in-metadata. Verifies the [ExportContextMenuEntry] is discovered. + var window = AppComposition.Current.GetExport(); + window.Show(); + var registry = AppComposition.Current.GetExport(); + + registry.Entries.Should().Contain( + e => e.Value is DecompileMetadataRowInNewTabCommand); + } } internal static class MetadataRowActivationTestExtensions { - public static void RaiseRowActivated(this MetadataTablePageModel page, object row) => + public static void RaiseRowActivated(this MetadataTablePageModel page, object row, bool openInNewTab = false) => typeof(MetadataTablePageModel) .GetMethod("RaiseRowActivated", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! - .Invoke(page, [row]); + .Invoke(page, [row, openInNewTab]); } diff --git a/ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs b/ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs new file mode 100644 index 000000000..5badd4764 --- /dev/null +++ b/ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs @@ -0,0 +1,81 @@ +// 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 Avalonia.Controls; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy.Docking; +using ILSpy.Languages; +using ILSpy.TextView; + +namespace ILSpy.Commands +{ + /// + /// Right-click on a metadata-grid row whose token resolves to an + /// → "Decompile to new tab". + /// Opens the entity in a fresh decompiler tab via , + /// matching the gesture exposes for the + /// assembly-tree right-click. Visible only when the click landed in a metadata grid + /// ( set) and the focused row resolves. + /// + [ExportContextMenuEntry( + Header = nameof(Resources.DecompileToNewPanel), + Category = "Metadata", + Order = 110)] + [Shared] + internal sealed class DecompileMetadataRowInNewTabCommand : IContextMenuEntry + { + readonly DockWorkspace dockWorkspace; + readonly LanguageService languageService; + + [ImportingConstructor] + public DecompileMetadataRowInNewTabCommand(DockWorkspace dockWorkspace, LanguageService languageService) + { + this.dockWorkspace = dockWorkspace; + this.languageService = languageService; + } + + public bool IsVisible(TextViewContext context) => ResolveRow(context) is not null; + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) + { + if (ResolveRow(context) is not { } row) + return; + var node = dockWorkspace.TryResolveRowToTreeNode(row); + if (node is null) + return; + var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; + dockWorkspace.OpenNewTab(content); + content.CurrentNodes = new[] { node }; + } + + static object? ResolveRow(TextViewContext context) + { + if (context.DataGrid is null) + return null; + if (context.OriginalSource is not DataGridCell cell) + return null; + return cell.DataContext; + } + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index f44221085..8a13c5b7f 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -337,25 +337,48 @@ namespace ILSpy.Docking NavigateToToken(new MetadataTokenReference(metadataFile, MetadataTokens.EntityHandle(token))); } - internal void OnMetadataRowActivated(object row) + internal void OnMetadataRowActivated(MetadataRowActivationEventArgs e) + { + // Row double-click: resolve the row's Token + metadataFile to an IEntity, then + // either select the matching tree node (single-tab reuse) or open it in a fresh + // decompiler tab (new-tab gesture — Shift+double-click or context menu). + var node = TryResolveRowToTreeNode(e.Row); + if (node is null) + return; + if (e.OpenInNewTab) + { + var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; + OpenNewTab(content); + content.CurrentNodes = new[] { node }; + } + else + { + assemblyTreeModel.SelectedItem = node; + } + } + + /// + /// Reflection-based resolution of a metadata-grid row to the matching assembly-tree + /// node, sharing the path between row double-click and the "Decompile to new tab" + /// context-menu entry. Returns when the row's token doesn't + /// resolve to an the tree knows how to model (heap rows, + /// AssemblyRef rows pointing at unloaded assemblies, etc.). + /// + internal ILSpyTreeNode? TryResolveRowToTreeNode(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; + return null; var tokenProp = row.GetType().GetProperty("Token"); if (tokenProp?.GetValue(row) is not int token || token == 0) - return; + return null; var handle = MetadataTokens.EntityHandle(token); if (handle.IsNil) - return; + return null; var assemblies = assemblyTreeModel.AssemblyList?.GetAssemblies(); if (assemblies is null) - return; + return null; LoadedAssembly? owningAssembly = null; foreach (var a in assemblies) { @@ -366,19 +389,17 @@ namespace ILSpy.Docking } } if (owningAssembly is null) - return; + return null; var ts = owningAssembly.GetTypeSystemOrNull(); if (ts?.MainModule is not MetadataModule metadataModule) - return; + return null; IEntity? entity; try { entity = metadataModule.ResolveEntity(handle); } - catch { return; } + catch { return null; } if (entity is null) - return; - var node = assemblyTreeModel.FindTreeNode(entity); - if (node is not null) - assemblyTreeModel.SelectedItem = node; + return null; + return assemblyTreeModel.FindTreeNode(entity); } public void NavigateToToken(MetadataTokenReference reference) diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index 56db9d884..6dc10e415 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -106,12 +106,15 @@ namespace ILSpy.ViewModels /// /// 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. + /// to an ; when + /// is true the entity + /// is decompiled into a fresh tab, otherwise it replaces the active tab via the + /// regular tree-selection path. /// - public event Action? RowActivated; + public event Action? RowActivated; - internal void RaiseRowActivated(object row) => RowActivated?.Invoke(row); + internal void RaiseRowActivated(object row, bool openInNewTab = false) + => RowActivated?.Invoke(new MetadataRowActivationEventArgs(row, openInNewTab)); static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = new(); @@ -318,4 +321,8 @@ namespace ILSpy.ViewModels /// The (row, column) pair clicked in a token cell. public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName); + + /// The activated row plus whether the activation was a new-tab gesture + /// (Shift+double-click or "Decompile to new tab" context-menu entry). + public sealed record MetadataRowActivationEventArgs(object Row, bool OpenInNewTab); } diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 500a2e873..b56d12dd6 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -64,14 +64,17 @@ namespace ILSpy.Views 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. + // Dispatch row-activation through the page model. Shift+double-click opens + // the entity in a new tab; a plain double-click reuses the active tab via + // the regular tree-selection path. if (DataContext is not MetadataTablePageModel page) return; var grid = this.FindControl("Grid"); if (grid?.SelectedItem is { } row) - page.RaiseRowActivated(row); + { + bool openInNewTab = (e.KeyModifiers & global::Avalonia.Input.KeyModifiers.Shift) != 0; + page.RaiseRowActivated(row, openInNewTab); + } } void OnKeyDown(object? sender, KeyEventArgs e)