diff --git a/ILSpy.Tests/Search/SearchResultContextMenuTests.cs b/ILSpy.Tests/Search/SearchResultContextMenuTests.cs new file mode 100644 index 000000000..98e8e1093 --- /dev/null +++ b/ILSpy.Tests/Search/SearchResultContextMenuTests.cs @@ -0,0 +1,100 @@ +// 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.Controls; +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.Search; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.Search; +using ILSpy.TreeNodes; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +/// +/// Right-clicking a search result opens the same registry-driven context menu the trees use. +/// (Regression: the Avalonia port dropped the menu the WPF host attached via +/// ContextMenuProvider.Add(listBox).) The selected result's entity reaches the entries +/// through TextViewContext.Reference, so entity entries such as Analyze and the +/// scope-search entries light up. +/// +[TestFixture] +public class SearchResultContextMenuTests +{ + [AvaloniaTest] + public async Task Right_Click_Menu_Surfaces_Entity_Entries_For_The_Selected_Result() + { + var (window, vm) = await TestHarness.BootAsync(); + AppComposition.Current.GetExport().ShowToolPane(SearchPaneModel.PaneContentId); + var pane = await window.WaitForComponent(); + var grid = pane.FindControl("SearchResults")!; + var model = (SearchPaneModel)pane.DataContext!; + + // A real member result: its Reference is the entity the menu entries read. + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var entity = (IEntity)typeNode.Member!; + var result = new MemberSearchResult { + Member = entity, + Name = entity.Name, + Location = entity.Namespace, + Assembly = entity.ParentModule?.AssemblyName ?? "", + Image = "", LocationImage = "", AssemblyImage = "", + }; + model.Results.Add(result); + grid.SelectedItem = result; + + grid.ContextMenu.Should().NotBeNull("a context menu must be attached to the results grid"); + + var registry = AppComposition.Current.GetExport(); + var menu = pane.BuildContextMenuForCurrentState(registry.Entries); + + menu.Should().NotBeNull("the selected entity result must produce a populated menu"); + var headers = menu!.Items.OfType().Select(i => i.Header?.ToString()).ToList(); + headers.Should().Contain(Resources.Analyze, "Analyze must be offered for an entity result"); + headers.Should().Contain(Resources.ScopeSearchToThisNamespace, + "a namespaced entity result offers scope-search-to-namespace"); + } + + [AvaloniaTest] + public async Task Menu_Is_Cancelled_When_No_Result_Is_Selected() + { + var (window, _) = await TestHarness.BootAsync(); + AppComposition.Current.GetExport().ShowToolPane(SearchPaneModel.PaneContentId); + var pane = await window.WaitForComponent(); + + var registry = AppComposition.Current.GetExport(); + // Nothing selected -> no entity context -> the entity entries hide, so the menu is empty. + var menu = pane.BuildContextMenuForCurrentState(registry.Entries); + (menu == null || !menu.Items.OfType().Any()) + .Should().BeTrue("with no result selected there are no entity entries to show"); + } +} diff --git a/ILSpy/Search/SearchPane.axaml b/ILSpy/Search/SearchPane.axaml index a56226fbe..a61a5910b 100644 --- a/ILSpy/Search/SearchPane.axaml +++ b/ILSpy/Search/SearchPane.axaml @@ -97,6 +97,7 @@ @@ -111,6 +112,7 @@ diff --git a/ILSpy/Search/SearchPane.axaml.cs b/ILSpy/Search/SearchPane.axaml.cs index 16a2f5d2b..eff66061b 100644 --- a/ILSpy/Search/SearchPane.axaml.cs +++ b/ILSpy/Search/SearchPane.axaml.cs @@ -17,6 +17,8 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; +using System.Linq; using Avalonia.Controls; using Avalonia.Input; @@ -25,11 +27,15 @@ using Avalonia.Threading; using ICSharpCode.ILSpyX.Search; +using ILSpy.AppEnv; +using ILSpy.TextView; + namespace ILSpy.Search { public partial class SearchPane : UserControl { SearchPaneModel? boundModel; + IReadOnlyList contextMenuEntries = Array.Empty(); public SearchPane() { @@ -39,6 +45,53 @@ namespace ILSpy.Search SearchResults.AddHandler(PointerPressedEvent, OnResultsPointerPressed, RoutingStrategies.Tunnel); SearchResults.AddHandler(PointerReleasedEvent, OnResultsPointerReleased, RoutingStrategies.Tunnel); SearchInput.KeyDown += OnSearchInputKeyDown; + AttachContextMenu(AppComposition.TryGetExport()?.Entries + ?? Array.Empty()); + } + + // Right-click a result for the same registry-driven menu the trees use (Analyze, scope + // search to namespace/assembly, ...). The selected result is exposed to the entries as + // context.Reference, the channel they read for the entity under the cursor. + internal void AttachContextMenu(IReadOnlyList entries) + { + contextMenuEntries = entries; + var menu = new ContextMenu(); + menu.Opening += OnContextMenuOpening; + SearchResults.ContextMenu = menu; + } + + void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (sender is not ContextMenu menu) + return; + var built = BuildContextMenuForCurrentState(contextMenuEntries); + if (built == null) + { + e.Cancel = true; + return; + } + menu.Items.Clear(); + foreach (var item in built.Items.OfType().ToArray()) + { + built.Items.Remove(item); + menu.Items.Add(item); + } + } + + internal ContextMenu? BuildContextMenuForCurrentState(IReadOnlyList entries) + => ContextMenuProvider.Build(entries, CreateContextMenuContext()); + + TextViewContext CreateContextMenuContext() + { + // Search results aren't tree nodes, so the entities are surfaced via Reference (what + // the search/analyze entries read), not SelectedTreeNodes. + var reference = SearchResults.SelectedItem is SearchResult { Reference: { } r } + ? new ReferenceSegment { Reference = r } + : null; + return new TextViewContext { + DataGrid = SearchResults, + Reference = reference, + }; } protected override void OnKeyDown(KeyEventArgs e) @@ -162,9 +215,11 @@ namespace ILSpy.Search void OnResultsPointerPressed(object? sender, PointerPressedEventArgs e) { - // Middle-click doesn't select a row on its own, so claim the row under the cursor - // here; the matching release then activates it in a new tab. - if (!e.GetCurrentPoint(SearchResults).Properties.IsMiddleButtonPressed) + // Neither middle- nor right-click selects a row on its own. Middle-click's release + // activates the row in a new tab; right-click needs the row selected so the context + // menu (built from SelectedItem) targets the result under the cursor. + var props = e.GetCurrentPoint(SearchResults).Properties; + if (!props.IsMiddleButtonPressed && !props.IsRightButtonPressed) return; if ((e.Source as Control)?.DataContext is SearchResult result) SearchResults.SelectedItem = result;