Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
834f2638d6
  1. 7
      ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs
  2. 56
      ILSpy.Tests/Metadata/MetadataRowActivationTests.cs
  3. 81
      ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs
  4. 53
      ILSpy/Docking/DockWorkspace.cs
  5. 15
      ILSpy/ViewModels/MetadataTablePageModel.cs
  6. 11
      ILSpy/Views/MetadataTablePage.axaml.cs

7
ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs

@ -28,6 +28,7 @@ using ICSharpCode.ILSpyX.TreeView; @@ -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 @@ -70,7 +71,8 @@ public class DecompileInNewViewTests
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
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<AssemblyTreeNode>("System.Linq");
@ -108,7 +110,8 @@ public class DecompileInNewViewTests @@ -108,7 +110,8 @@ public class DecompileInNewViewTests
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
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!;

56
ILSpy.Tests/Metadata/MetadataRowActivationTests.cs

@ -23,7 +23,10 @@ using Avalonia.Headless.NUnit; @@ -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 @@ -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<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 metadataTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
var objectRow = metadataTab.Items.Cast<TypeDefTableTreeNode.TypeDefEntry>()
.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<MainWindow>();
window.Show();
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
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]);
}

81
ILSpy/Commands/DecompileMetadataRowInNewTabCommand.cs

@ -0,0 +1,81 @@ @@ -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
{
/// <summary>
/// Right-click on a metadata-grid row whose token resolves to an
/// <see cref="ICSharpCode.Decompiler.TypeSystem.IEntity"/> → "Decompile to new tab".
/// Opens the entity in a fresh decompiler tab via <see cref="DockWorkspace.OpenNewTab"/>,
/// matching the gesture <see cref="DecompileInNewViewCommand"/> exposes for the
/// assembly-tree right-click. Visible only when the click landed in a metadata grid
/// (<see cref="TextViewContext.DataGrid"/> set) and the focused row resolves.
/// </summary>
[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;
}
}
}

53
ILSpy/Docking/DockWorkspace.cs

@ -337,25 +337,48 @@ namespace ILSpy.Docking @@ -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;
}
}
/// <summary>
/// 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 <see langword="null"/> when the row's token doesn't
/// resolve to an <see cref="IEntity"/> the tree knows how to model (heap rows,
/// AssemblyRef rows pointing at unloaded assemblies, etc.).
/// </summary>
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 @@ -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)

15
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -106,12 +106,15 @@ namespace ILSpy.ViewModels @@ -106,12 +106,15 @@ namespace ILSpy.ViewModels
/// <summary>
/// Raised when the user double-clicks (or otherwise activates) a metadata grid
/// row. The dock workspace resolves the row's <c>metadataFile</c> + <c>Token</c>
/// to an <see cref="ICSharpCode.Decompiler.TypeSystem.IEntity"/> and selects the
/// matching tree node, which opens the entity in the decompiler view.
/// to an <see cref="ICSharpCode.Decompiler.TypeSystem.IEntity"/>; when
/// <see cref="MetadataRowActivationEventArgs.OpenInNewTab"/> is true the entity
/// is decompiled into a fresh tab, otherwise it replaces the active tab via the
/// regular tree-selection path.
/// </summary>
public event Action<object>? RowActivated;
public event Action<MetadataRowActivationEventArgs>? 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 @@ -318,4 +321,8 @@ namespace ILSpy.ViewModels
/// <summary>The (row, column) pair clicked in a token cell.</summary>
public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName);
/// <summary>The activated row plus whether the activation was a new-tab gesture
/// (Shift+double-click or "Decompile to new tab" context-menu entry).</summary>
public sealed record MetadataRowActivationEventArgs(object Row, bool OpenInNewTab);
}

11
ILSpy/Views/MetadataTablePage.axaml.cs

@ -64,14 +64,17 @@ namespace ILSpy.Views @@ -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<DataGrid>("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)

Loading…
Cancel
Save