From b2d2fea2376e069ce01138365791bd2587e049f7 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 6 Jun 2026 16:14:17 +0200 Subject: [PATCH] Offer Decompile-to-new-tab and scope-search on a code symbol Finishes the decompiler text-view context menu: the remaining tree-only entries now also act on a right-clicked symbol (IEntity). Decompile to new tab opens the entity's definition in a fresh tab -- which required honouring the previously-dead InNewTabPage flag on NavigateToReferenceEventArgs (OnNavigateToReference now opens the resolved node via OpenNodeInNewTab). Scope search to assembly/namespace read the entity's ParentModule.AssemblyName / Namespace, matching the existing inassembly:/innamespace: filters. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../ReferenceScopeAndNewTabTests.cs | 110 ++++++++++++++++++ ILSpy/AssemblyTree/AssemblyTreeModel.cs | 11 +- ILSpy/Commands/DecompileInNewViewCommand.cs | 28 +++-- .../ScopeSearchToAssemblyContextMenuEntry.cs | 23 ++-- .../ScopeSearchToNamespaceContextMenuEntry.cs | 27 ++--- 5 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs diff --git a/ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs b/ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs new file mode 100644 index 000000000..4fe363bd1 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/ReferenceScopeAndNewTabTests.cs @@ -0,0 +1,110 @@ +// 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 Avalonia.Threading; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Properties; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Search; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The remaining decompiler text-view entries that act on a clicked symbol (IEntity): "Decompile to +/// new tab", and the two "Scope search to ..." entries. +/// +[TestFixture] +public class ReferenceScopeAndNewTabTests +{ + static TextViewContext RefContext(IEntity entity) + => new() { Reference = new ReferenceSegment { Reference = entity } }; + + [AvaloniaTest] + public async Task Decompile_To_New_Tab_On_A_Reference_Opens_A_New_Tab() + { + var (_, vm) = await TestHarness.BootAsync(3); + // Two entries share the DecompileToNewPanel header (tree + metadata-row); pick the tree/code one. + var entry = AppComposition.Current.GetExport().Entries + .First(e => e.Value.GetType().Name == "DecompileInNewViewCommand").Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var entity = (IEntity)typeNode.Member!; + + entry.IsVisible(RefContext(entity)).Should().BeTrue("a clicked entity must surface Decompile to new tab"); + + int before = vm.DockWorkspace.Documents!.VisibleDockables!.OfType().Count(); + entry.Execute(RefContext(entity)); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + vm.DockWorkspace.Documents!.VisibleDockables!.OfType().Count() + .Should().BeGreaterThan(before, "Decompile to new tab on a code reference must open a new document tab"); + } + + [AvaloniaTest] + public async Task Scope_Search_To_Assembly_On_A_Reference_Sets_The_inassembly_Filter() + { + var (_, vm) = await TestHarness.BootAsync(3); + var entry = AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.ScopeSearchToThisAssembly)); + var entity = (IEntity)vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable").Member!; + + entry.IsVisible(RefContext(entity)).Should().BeTrue(); + entry.Execute(RefContext(entity)); + + var search = AppComposition.Current.GetExport(); + search.SearchTerm.Should().Contain("inassembly:").And.Contain("System.Linq", + "scoping to the reference's assembly must prepend inassembly:"); + } + + [AvaloniaTest] + public async Task Scope_Search_To_Namespace_On_A_Reference_Sets_The_innamespace_Filter() + { + var (_, vm) = await TestHarness.BootAsync(3); + var entry = AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.ScopeSearchToThisNamespace)); + var entity = (IEntity)vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable").Member!; + + entry.IsVisible(RefContext(entity)).Should().BeTrue(); + entry.Execute(RefContext(entity)); + + var search = AppComposition.Current.GetExport(); + search.SearchTerm.Should().Contain("innamespace:").And.Contain("System.Linq", + "scoping to the reference's namespace must prepend innamespace:"); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 2e4e30a67..01c49974e 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -179,7 +179,16 @@ namespace ILSpy.AssemblyTree var resolved = FindTreeNode(entity); if (resolved == null) return; - SelectedItem = resolved; + if (e.InNewTabPage) + { + // Open the definition in a fresh carve-out tab instead of replacing the current view + // (e.g. "Decompile to new tab" on a symbol in the code). + TryGetExport()?.OpenNodeInNewTab(resolved); + } + else + { + SelectedItem = resolved; + } // Source is the originally-analysed entity (set by AnalyzerEntityTreeNode.ActivateItem). // Push it onto the active decompiler tab's HighlightedReference so the editor view // paints local-reference marks on every match once the new Text lands. diff --git a/ILSpy/Commands/DecompileInNewViewCommand.cs b/ILSpy/Commands/DecompileInNewViewCommand.cs index 1c5e29e32..5b1d62e2d 100644 --- a/ILSpy/Commands/DecompileInNewViewCommand.cs +++ b/ILSpy/Commands/DecompileInNewViewCommand.cs @@ -54,26 +54,34 @@ namespace ILSpy.Commands } public bool IsVisible(TextViewContext context) - => SelectedNodes(context).Any(); + => SelectedNodes(context).Any() || ReferencedEntity(context) is not null; - public bool IsEnabled(TextViewContext context) - => SelectedNodes(context).Any(); + public bool IsEnabled(TextViewContext context) => IsVisible(context); public void Execute(TextViewContext context) { var nodes = SelectedNodes(context).ToArray(); - if (nodes.Length == 0) + if (nodes.Length > 0) + { + var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; + // Single-node selection carries a SourceNode so the tab/tree stay in lockstep + // when the user flips tabs. Multi-node selections leave SourceNode null — + // no single tree row represents the union. + dockWorkspace.OpenNewTab(content, sourceNode: nodes.Length == 1 ? nodes[0] : null); + content.CurrentNodes = nodes; return; - var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; - // Single-node selection carries a SourceNode so the tab/tree stay in lockstep - // when the user flips tabs. Multi-node selections leave SourceNode null — - // no single tree row represents the union. - dockWorkspace.OpenNewTab(content, sourceNode: nodes.Length == 1 ? nodes[0] : null); - content.CurrentNodes = nodes; + } + // Right-click on a symbol in the decompiled code: open its definition in a new tab. The + // navigate handler resolves the entity to its tree node and opens it via OpenNodeInNewTab. + if (ReferencedEntity(context) is { } entity) + Util.MessageBus.Send(this, new Util.NavigateToReferenceEventArgs(entity, inNewTabPage: true)); } static System.Collections.Generic.IEnumerable SelectedNodes(TextViewContext context) => context.SelectedTreeNodes?.OfType() ?? System.Linq.Enumerable.Empty(); + + static ICSharpCode.Decompiler.TypeSystem.IEntity? ReferencedEntity(TextViewContext context) + => context.Reference?.Reference as ICSharpCode.Decompiler.TypeSystem.IEntity; } } diff --git a/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs b/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs index 46569de15..e7175b588 100644 --- a/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs +++ b/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs @@ -19,6 +19,7 @@ using System.Composition; using System.Linq; +using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpy.Properties; using ILSpy.TreeNodes; @@ -44,26 +45,26 @@ namespace ILSpy.Search this.searchPane = searchPane; } - public bool IsVisible(TextViewContext context) - { - if (context.SelectedTreeNodes is not { Length: > 0 } nodes) - return false; - return nodes.All(n => n is AssemblyTreeNode); - } + public bool IsVisible(TextViewContext context) => AssemblyName(context) is not null; public bool IsEnabled(TextViewContext context) => IsVisible(context); public void Execute(TextViewContext context) { - if (context.SelectedTreeNodes is not { Length: > 0 } nodes) - return; - var asm = nodes.OfType().FirstOrDefault(); - if (asm == null) + if (AssemblyName(context) is not { } name) return; - var name = asm.LoadedAssembly.ShortName; searchPane.SearchTerm = MergeScopePrefix(searchPane.SearchTerm, "inassembly", name); } + // The assembly to scope to: a selected AssemblyTreeNode in the tree, or the assembly that owns + // the symbol under a right-clicked code reference. + static string? AssemblyName(TextViewContext context) + { + if (context.SelectedTreeNodes is { Length: > 0 } nodes && nodes.All(n => n is AssemblyTreeNode)) + return nodes.OfType().FirstOrDefault()?.LoadedAssembly.ShortName; + return (context.Reference?.Reference as IEntity)?.ParentModule?.AssemblyName; + } + internal static string MergeScopePrefix(string current, string prefix, string value) { // Strip any existing same-prefix token so consecutive scope clicks REPLACE rather diff --git a/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs b/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs index c79c2eb9e..cee4d9232 100644 --- a/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs +++ b/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs @@ -19,6 +19,7 @@ using System.Composition; using System.Linq; +using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpy.Properties; using ILSpy.TreeNodes; @@ -43,24 +44,24 @@ namespace ILSpy.Search this.searchPane = searchPane; } - public bool IsVisible(TextViewContext context) - { - if (context.SelectedTreeNodes is not { Length: > 0 } nodes) - return false; - return nodes.All(n => n is NamespaceTreeNode); - } + public bool IsVisible(TextViewContext context) => !string.IsNullOrEmpty(Namespace(context)); public bool IsEnabled(TextViewContext context) => IsVisible(context); public void Execute(TextViewContext context) { - if (context.SelectedTreeNodes is not { Length: > 0 } nodes) - return; - var ns = nodes.OfType().FirstOrDefault(); - if (ns == null || string.IsNullOrEmpty(ns.Name)) - return; - searchPane.SearchTerm = ScopeSearchToAssemblyContextMenuEntry.MergeScopePrefix( - searchPane.SearchTerm, "innamespace", ns.Name); + if (Namespace(context) is { Length: > 0 } ns) + searchPane.SearchTerm = ScopeSearchToAssemblyContextMenuEntry.MergeScopePrefix( + searchPane.SearchTerm, "innamespace", ns); + } + + // The namespace to scope to: a selected NamespaceTreeNode in the tree, or the namespace of the + // symbol under a right-clicked code reference. The empty (global) namespace doesn't scope. + static string? Namespace(TextViewContext context) + { + if (context.SelectedTreeNodes is { Length: > 0 } nodes && nodes.All(n => n is NamespaceTreeNode)) + return nodes.OfType().FirstOrDefault()?.Name; + return (context.Reference?.Reference as IEntity)?.Namespace; } } }