From cd1bd48db9c20f523c44a28a2e7970d5d4dacabd Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 5 May 2026 19:27:32 +0200 Subject: [PATCH] Wire context-menu host onto the assembly tree DataGrid AssemblyListPane attaches a ContextMenu to its TreeGrid at construction time. The menu's Opening handler builds entries from the ContextMenuEntryRegistry against a fresh TextViewContext (the live selection + the grid as OriginalSource), and cancels Opening when no entry would be visible so an empty popup never flashes up. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../AssemblyTreeContextMenuTests.cs | 121 ++++++++++++++++++ ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 75 +++++++++++ 2 files changed, 196 insertions(+) create mode 100644 ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs new file mode 100644 index 000000000..c18e952b1 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs @@ -0,0 +1,121 @@ +// 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 Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class AssemblyTreeContextMenuTests +{ + [AvaloniaTest] + public async Task ContextMenu_Is_Attached_To_The_Assembly_Tree_Grid() + { + // AssemblyListPane wires a context-menu host onto its DataGrid at construction time so + // any [ExportContextMenuEntry]-tagged commands can drop into the right-click menu + // without further per-entry plumbing. + + // Arrange + Act — boot the window so the pane materialises. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + // Assert — TreeGrid carries a ContextMenu. (The menu may be empty if no entries are + // registered yet; verifies only that the host is in place.) + var pane = await window.WaitForComponent(); + var grid = await pane.WaitForComponent(); + grid.ContextMenu.Should().NotBeNull(); + } + + [AvaloniaTest] + public async Task Context_Menu_Shows_Visible_Entries_With_Selected_Tree_Nodes_In_Context() + { + // When the user right-clicks a tree row, the pane builds a TextViewContext from the + // current selection and feeds it through ContextMenuProvider.Build with whichever + // entries the test injects. Visible entries land in the menu; the entry receives the + // originating tab grid + the selected nodes when invoked. + + // Arrange — boot, select an assembly node, install a stub entry that records the + // context it sees on Execute. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var pane = await window.WaitForComponent(); + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + vm.AssemblyTreeModel.SelectedItem = assemblyNode; + + TextViewContext? executionContext = null; + var entry = new RecordingEntry(c => executionContext = c); + var export = new StubExport(entry, new ContextMenuEntryMetadata { Header = "Probe", Order = 0 }); + + // Act 1 — re-attach the context menu with our stub entries. + pane.AttachContextMenu(new IContextMenuEntryExport[] { export }); + var grid = await pane.WaitForComponent(); + var menu = grid.ContextMenu!; + + // Trigger the build path the live Opening event would take. + var built = pane.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export }); + + // Assert 1 — menu carries our entry. + built.Should().NotBeNull(); + var item = built!.Items.OfType().Single(); + ((string?)item.Header).Should().Be("Probe"); + + // Act 2 — invoke the click handler that the Build attached. + item.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); + + // Assert 2 — entry's Execute saw the right context: the tree grid + the selection. + executionContext.Should().NotBeNull(); + ReferenceEquals(executionContext!.TreeGrid, grid).Should().BeTrue(); + executionContext.SelectedTreeNodes.Should().NotBeNull(); + executionContext.SelectedTreeNodes!.Should().Contain((SharpTreeNode)assemblyNode); + } + + sealed class RecordingEntry(System.Action onExecute) : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => true; + public bool IsEnabled(TextViewContext context) => true; + public void Execute(TextViewContext context) => onExecute(context); + } + + sealed class StubExport(IContextMenuEntry entry, ContextMenuEntryMetadata metadata) : IContextMenuEntryExport + { + public IContextMenuEntry Value { get; } = entry; + public ContextMenuEntryMetadata Metadata { get; } = metadata; + } +} diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index ca3a3f75a..e617e30e3 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -18,6 +18,7 @@ using System; using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -45,6 +46,80 @@ namespace ILSpy.AssemblyTree InitializeComponent(); TreeGrid.DoubleTapped += OnTreeGridDoubleTapped; TreeGrid.KeyDown += OnTreeGridKeyDown; + + // Context-menu host. Tests bypass this and re-attach via AttachContextMenu so they + // can inject stub entries — at app-runtime we resolve the registry through the + // composition host. Both paths route through the same Opening handler. + var registry = TryGetContextMenuRegistry(); + AttachContextMenu(registry?.Entries ?? System.Array.Empty()); + } + + static ContextMenuEntryRegistry? TryGetContextMenuRegistry() + { + try + { + return AppEnv.AppComposition.Current.GetExport(); + } + catch + { + // Composition isn't available in design-time previews; the empty-entries + // path leaves the menu host in place but cancels Opening, matching the + // "no entries registered" UX. + return null; + } + } + + IReadOnlyList contextMenuEntries = System.Array.Empty(); + + /// + /// Replaces the active context-menu entries. App-runtime call: once at construction + /// with the registry's entries. Test call: directly with stub entries to exercise the + /// menu without going through MEF. + /// + internal void AttachContextMenu(IReadOnlyList entries) + { + contextMenuEntries = entries; + var menu = new ContextMenu(); + menu.Opening += OnContextMenuOpening; + TreeGrid.ContextMenu = menu; + } + + void OnContextMenuOpening(object? sender, global::System.ComponentModel.CancelEventArgs e) + { + if (sender is not ContextMenu menu) + return; + var built = BuildContextMenuForCurrentState(contextMenuEntries); + if (built == null) + { + e.Cancel = true; + return; + } + // Move the built menu's items into the live ContextMenu (a single Items collection + // can't host the same MenuItem twice — copy then drop the donor). + menu.Items.Clear(); + foreach (var item in System.Linq.Enumerable.ToArray(built.Items.OfType())) + { + built.Items.Remove(item); + menu.Items.Add(item); + } + } + + /// + /// Builds the context menu using the supplied entries against the pane's current + /// selection. Internal so tests can drive the build path without raising the live + /// Opening event. + /// + internal ContextMenu? BuildContextMenuForCurrentState(IReadOnlyList entries) + => ContextMenuProvider.Build(entries, CreateContextMenuContext()); + + TextViewContext CreateContextMenuContext() + { + var nodes = (DataContext as AssemblyTreeModel)?.SelectedItems.ToArray() + ?? System.Array.Empty(); + return new TextViewContext { + TreeGrid = TreeGrid, + SelectedTreeNodes = nodes, + }; } void OnTreeGridKeyDown(object? sender, KeyEventArgs e)