From a9a43200ecbb482ba6d855a1621d74da2ed7e3a7 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 6 May 2026 09:39:00 +0200 Subject: [PATCH] Copy-fully-qualified-name context-menu entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the IMemberTreeNode interface (the same contract WPF uses) and makes the five entity-bearing tree-node types — Type / Method / Field / Property / Event — implement it so generic tooling can reach the underlying IEntity without knowing which concrete tree node it has on hand. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../CopyFullyQualifiedNameTests.cs | 118 ++++++++++++++++++ .../CopyFullyQualifiedNameContextMenuEntry.cs | 69 ++++++++++ ILSpy/TreeNodes/EventTreeNode.cs | 4 +- ILSpy/TreeNodes/FieldTreeNode.cs | 4 +- ILSpy/TreeNodes/IMemberTreeNode.cs | 34 +++++ ILSpy/TreeNodes/MethodTreeNode.cs | 4 +- ILSpy/TreeNodes/PropertyTreeNode.cs | 4 +- ILSpy/TreeNodes/TypeTreeNode.cs | 6 +- 8 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs create mode 100644 ILSpy/Commands/CopyFullyQualifiedNameContextMenuEntry.cs create mode 100644 ILSpy/TreeNodes/IMemberTreeNode.cs diff --git a/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs b/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs new file mode 100644 index 000000000..daa974799 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs @@ -0,0 +1,118 @@ +// 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 ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class CopyFullyQualifiedNameTests +{ + [AvaloniaTest] + public async Task Copy_Entry_Is_Registered() + { + // "Copy fully qualified name" appears as an [ExportContextMenuEntry] tagged with the + // localized "CopyName" header. Verifies it's discovered through MEF. + + // Arrange + Act — boot. + var window = AppComposition.Current.GetExport(); + window.Show(); + var registry = AppComposition.Current.GetExport(); + + // Assert — registry contains an entry with the CopyName resource header. + registry.Entries.Should().Contain( + e => e.Metadata.Header == nameof(Resources.CopyName)); + } + + [AvaloniaTest] + public async Task Copy_Entry_Is_Visible_Only_For_Single_Member_Tree_Node() + { + // IsVisible: exactly one selected node, and it must be an IMemberTreeNode (the + // interface implemented by Type/Method/Field/Property/Event tree nodes that exposes + // an IEntity). Hidden for assembly nodes, multi-selections, and empty selections. + + // Arrange — boot, locate the registered entry + a sample type and assembly node. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.CopyName)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + + // Assert — visible only for a single member-tree-node selection. + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }) + .Should().BeTrue(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode, assemblyNode } }) + .Should().BeFalse("multi-selection isn't supported"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) + .Should().BeFalse("assembly nodes are not IMemberTreeNodes"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) + .Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Copy_Entry_Computes_The_Reflection_Name_Of_The_Selected_Member() + { + // CopyName's payload is the IEntity.ReflectionName of the selected member — the same + // stable language-independent identifier used by FindNodeByPath et al. Tested by + // reading the public computed property; the actual clipboard write is a thin wrapper + // around that text. + + // Arrange — boot, locate the type node + the registered entry's typed wrapper. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var registry = AppComposition.Current.GetExport(); + var entry = (CopyFullyQualifiedNameContextMenuEntry)registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.CopyName)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + + // Act — compute the would-be-copied text for that selection. + var text = entry.GetTextToCopy(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); + + // Assert — matches the expected ReflectionName. + text.Should().Be("System.Linq.Enumerable"); + } +} diff --git a/ILSpy/Commands/CopyFullyQualifiedNameContextMenuEntry.cs b/ILSpy/Commands/CopyFullyQualifiedNameContextMenuEntry.cs new file mode 100644 index 000000000..caf463ed1 --- /dev/null +++ b/ILSpy/Commands/CopyFullyQualifiedNameContextMenuEntry.cs @@ -0,0 +1,69 @@ +// 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; +using Avalonia.Controls; +using Avalonia.Input.Platform; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Copy fully qualified name". Visible whenever the selection is exactly + /// one tree node that wraps a TypeSystem entity (Type/Method/Field/Property/Event). + /// Copies the entity's + /// — the language-independent identifier used by FindNodeByPath etc. — to the clipboard. + /// + [ExportContextMenuEntry(Header = nameof(Resources.CopyName), Order = 9999)] + [Shared] + public sealed class CopyFullyQualifiedNameContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => GetMemberNodeFromContext(context) != null; + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) + { + var text = GetTextToCopy(context); + if (text == null) + return; + // The clipboard is owned by the TopLevel that hosts the source visual; for the + // assembly tree that's the main window. SetTextAsync runs on the UI thread and we + // don't await — the menu item already returned by then. + var topLevel = context.OriginalSource is Visual src ? TopLevel.GetTopLevel(src) + : context.TreeGrid is { } grid ? TopLevel.GetTopLevel(grid) + : null; + topLevel?.Clipboard?.SetTextAsync(text); + } + + /// Public for testing: returns the text the entry would write to the clipboard + /// for the given context, or when the entry isn't applicable. + public string? GetTextToCopy(TextViewContext context) + => GetMemberNodeFromContext(context)?.Member?.ReflectionName; + + static IMemberTreeNode? GetMemberNodeFromContext(TextViewContext context) + => context.SelectedTreeNodes is { Length: 1 } nodes + ? nodes[0] as IMemberTreeNode + : null; + } +} diff --git a/ILSpy/TreeNodes/EventTreeNode.cs b/ILSpy/TreeNodes/EventTreeNode.cs index d6913fc89..105e96055 100644 --- a/ILSpy/TreeNodes/EventTreeNode.cs +++ b/ILSpy/TreeNodes/EventTreeNode.cs @@ -26,10 +26,12 @@ using ILSpy.Languages; namespace ILSpy.TreeNodes { - sealed class EventTreeNode : ILSpyTreeNode + sealed class EventTreeNode : ILSpyTreeNode, IMemberTreeNode { public IEvent EventDefinition { get; } + public IEntity? Member => EventDefinition; + public EventTreeNode(IEvent ev) { EventDefinition = ev ?? throw new ArgumentNullException(nameof(ev)); diff --git a/ILSpy/TreeNodes/FieldTreeNode.cs b/ILSpy/TreeNodes/FieldTreeNode.cs index 02b248f85..bd6da6aa9 100644 --- a/ILSpy/TreeNodes/FieldTreeNode.cs +++ b/ILSpy/TreeNodes/FieldTreeNode.cs @@ -26,10 +26,12 @@ using ILSpy.Languages; namespace ILSpy.TreeNodes { - sealed class FieldTreeNode : ILSpyTreeNode + sealed class FieldTreeNode : ILSpyTreeNode, IMemberTreeNode { public IField FieldDefinition { get; } + public IEntity? Member => FieldDefinition; + public FieldTreeNode(IField field) { FieldDefinition = field ?? throw new ArgumentNullException(nameof(field)); diff --git a/ILSpy/TreeNodes/IMemberTreeNode.cs b/ILSpy/TreeNodes/IMemberTreeNode.cs new file mode 100644 index 000000000..d10e29acd --- /dev/null +++ b/ILSpy/TreeNodes/IMemberTreeNode.cs @@ -0,0 +1,34 @@ +// 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 ICSharpCode.Decompiler.TypeSystem; + +namespace ILSpy.TreeNodes +{ + /// + /// Contract implemented by every tree node that wraps a TypeSystem entity (types, + /// methods, fields, properties, events). Lets context-menu entries and other generic + /// tooling that wants the underlying reach it without knowing + /// which concrete tree-node class it has on hand. + /// + public interface IMemberTreeNode + { + /// The underlying entity, or null when it cannot be resolved. + IEntity? Member { get; } + } +} diff --git a/ILSpy/TreeNodes/MethodTreeNode.cs b/ILSpy/TreeNodes/MethodTreeNode.cs index 95e292d18..1f051dece 100644 --- a/ILSpy/TreeNodes/MethodTreeNode.cs +++ b/ILSpy/TreeNodes/MethodTreeNode.cs @@ -26,10 +26,12 @@ using ILSpy.Languages; namespace ILSpy.TreeNodes { - sealed class MethodTreeNode : ILSpyTreeNode + sealed class MethodTreeNode : ILSpyTreeNode, IMemberTreeNode { public IMethod MethodDefinition { get; } + public IEntity? Member => MethodDefinition; + public MethodTreeNode(IMethod method) { MethodDefinition = method ?? throw new ArgumentNullException(nameof(method)); diff --git a/ILSpy/TreeNodes/PropertyTreeNode.cs b/ILSpy/TreeNodes/PropertyTreeNode.cs index 516a8e4ae..e1fd09e92 100644 --- a/ILSpy/TreeNodes/PropertyTreeNode.cs +++ b/ILSpy/TreeNodes/PropertyTreeNode.cs @@ -26,10 +26,12 @@ using ILSpy.Languages; namespace ILSpy.TreeNodes { - sealed class PropertyTreeNode : ILSpyTreeNode + sealed class PropertyTreeNode : ILSpyTreeNode, IMemberTreeNode { public IProperty PropertyDefinition { get; } + public IEntity? Member => PropertyDefinition; + public PropertyTreeNode(IProperty property) { PropertyDefinition = property ?? throw new ArgumentNullException(nameof(property)); diff --git a/ILSpy/TreeNodes/TypeTreeNode.cs b/ILSpy/TreeNodes/TypeTreeNode.cs index 54c40ee44..12d9057a9 100644 --- a/ILSpy/TreeNodes/TypeTreeNode.cs +++ b/ILSpy/TreeNodes/TypeTreeNode.cs @@ -30,7 +30,7 @@ using ILSpy.Languages; namespace ILSpy.TreeNodes { - sealed class TypeTreeNode : ILSpyTreeNode + sealed class TypeTreeNode : ILSpyTreeNode, IMemberTreeNode { readonly TypeDefinitionHandle handle; readonly MetadataFile module; @@ -38,6 +38,10 @@ namespace ILSpy.TreeNodes public TypeDefinitionHandle Handle => handle; public MetadataFile Module => module; + // IEntity for the wrapped type. Resolution is lazy and may return null when the + // type system can't be built (e.g. broken assemblies); callers must handle null. + public IEntity? Member => ResolveTypeDefinition(); + public TypeTreeNode(TypeDefinitionHandle handle, MetadataFile module) { this.handle = handle;