From fdd4e38fb038db3b19163d7acee4794acf5184f3 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 6 May 2026 10:16:10 +0200 Subject: [PATCH] Search-Microsoft-Docs context-menu entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click on a Type / Method / Field / Property / Event / Namespace row opens the MS-Docs landing page for the selected entity in the default browser. URL shape: https://learn.microsoft.com/dotnet/api/ with backticks (generic arity) → hyphens, '+' (nested types) → '.', and '.ctor' → '-ctor' so constructors resolve correctly. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/ContextMenus/SearchMsdnTests.cs | 111 +++++++++++++++ ILSpy/Commands/SearchMsdnContextMenuEntry.cs | 135 +++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 ILSpy.Tests/ContextMenus/SearchMsdnTests.cs create mode 100644 ILSpy/Commands/SearchMsdnContextMenuEntry.cs diff --git a/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs b/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs new file mode 100644 index 000000000..cc882a817 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs @@ -0,0 +1,111 @@ +// 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 SearchMsdnTests +{ + [AvaloniaTest] + public async Task SearchMsdn_Entry_Is_Registered() + { + // "Search Microsoft Docs..." is exported as a [ExportContextMenuEntry] tagged with + // the SearchMSDN resource. Verifies it shows up via MEF. + + // Arrange + Act — boot. + var window = AppComposition.Current.GetExport(); + window.Show(); + var registry = AppComposition.Current.GetExport(); + + // Assert + registry.Entries.Should().Contain( + e => e.Metadata.Header == nameof(Resources.SearchMSDN)); + } + + [AvaloniaTest] + public async Task SearchMsdn_Is_Visible_Only_For_Type_Member_Or_Namespace_Selections() + { + // IsVisible: only the entity-bearing tree-node kinds plus NamespaceTreeNode (which has + // its own MS-Docs landing page). Hidden for assembly nodes, references folders, etc. + + // Arrange — boot, locate entry + sample nodes. + 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 = (SearchMsdnContextMenuEntry)registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.SearchMSDN)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }) + .Should().BeTrue(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) + .Should().BeFalse("assembly nodes don't have an MS-Docs page"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) + .Should().BeFalse(); + } + + [AvaloniaTest] + public async Task SearchMsdn_Builds_The_Expected_Microsoft_Docs_URL() + { + // The URL is `https://learn.microsoft.com/dotnet/api/`, lowercased, + // with backticks turned into hyphens (generic arity) and `+` into `.` (nested types). + // Constructors get a `..ctor` → `-ctor` rename so the docs site resolves them. + + // Arrange — boot. + 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 = (SearchMsdnContextMenuEntry)registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.SearchMSDN)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + + // Assert — the type's docs URL. + entry.GetMsdnUrl(typeNode) + .Should().Be("https://learn.microsoft.com/dotnet/api/system.linq.enumerable"); + } +} diff --git a/ILSpy/Commands/SearchMsdnContextMenuEntry.cs b/ILSpy/Commands/SearchMsdnContextMenuEntry.cs new file mode 100644 index 000000000..4d67a0002 --- /dev/null +++ b/ILSpy/Commands/SearchMsdnContextMenuEntry.cs @@ -0,0 +1,135 @@ +// 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; +using System.Composition; +using System.Diagnostics; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Search Microsoft Docs..." — opens the MS-Docs landing page for the + /// selected type / member / namespace in the default browser. Visible on the + /// entity-bearing tree-node kinds plus ; enabled only when + /// the entity is publicly accessible (private members aren't on the docs site). + /// + [ExportContextMenuEntry(Header = nameof(Resources.SearchMSDN), Order = 9999)] + [Shared] + public sealed class SearchMsdnContextMenuEntry : IContextMenuEntry + { + const string MsdnPrefix = "https://learn.microsoft.com/dotnet/api/"; + + public bool IsVisible(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.All(IsSearchable); + } + + public bool IsEnabled(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.All(IsAccessible); + } + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return; + foreach (var node in nodes) + { + if (GetMsdnUrl(node) is { Length: > 0 } url) + OpenInBrowser(url); + } + } + + /// Public for tests: builds the docs URL for the supplied node, or returns + /// an empty string when the node has no associated docs page. + public string GetMsdnUrl(SharpTreeNode node) + { + switch (node) + { + case NamespaceTreeNode ns when !string.IsNullOrEmpty(ns.Name): + return (MsdnPrefix + ns.Name).ToLowerInvariant(); + case IMemberTreeNode m when m.Member is { } entity: + // Reflection-name → docs path: backticks (generic arity) become hyphens, + // `+` (nested type) becomes `.`, and ".ctor" gets a "-ctor" tail because + // the docs site routes constructors that way. + var name = entity.ReflectionName.Replace('`', '-').Replace('+', '.'); + if (name.EndsWith("..ctor", StringComparison.Ordinal)) + name = name.Substring(0, name.Length - 5) + "-ctor"; + return (MsdnPrefix + name).ToLowerInvariant(); + default: + return string.Empty; + } + } + + static bool IsSearchable(SharpTreeNode node) + => node is NamespaceTreeNode or TypeTreeNode or EventTreeNode or FieldTreeNode + or PropertyTreeNode or MethodTreeNode; + + static bool IsAccessible(SharpTreeNode node) + { + switch (node) + { + case NamespaceTreeNode ns: + return !string.IsNullOrEmpty(ns.Name); + case IMemberTreeNode m when m.Member is { } entity: + if (!IsExternallyVisible(entity.Accessibility)) + return false; + if (entity is IMember member) + { + var declaring = member.DeclaringTypeDefinition; + if (declaring is null || !IsExternallyVisible(declaring.Accessibility)) + return false; + // Members of delegates / enums don't have separate docs pages. + if (declaring.Kind is TypeKind.Delegate or TypeKind.Enum) + return false; + } + return true; + default: + return false; + } + } + + static bool IsExternallyVisible(Accessibility a) + => a is Accessibility.Public or Accessibility.Protected or Accessibility.ProtectedOrInternal; + + static void OpenInBrowser(string url) + { + try + { + // UseShellExecute=true delegates to the OS default browser on every supported + // platform. + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Failure to launch the browser is non-fatal — the menu item already returned. + } + } + } +}