From 161cef4628883140f0740ca783b1e98288105365 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 5 May 2026 09:57:26 +0200 Subject: [PATCH] Context-menu framework (IContextMenuEntry + Export) Adds the MEF extension surface that lets commands contribute right-click entries the same way they contribute main-menu and toolbar items today: Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../ContextMenus/ContextMenuFrameworkTests.cs | 161 +++++++++++++++++ ILSpy/ContextMenuEntry.cs | 124 +++++++++++++ ILSpy/ContextMenuProvider.cs | 164 ++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs create mode 100644 ILSpy/ContextMenuEntry.cs create mode 100644 ILSpy/ContextMenuProvider.cs diff --git a/ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs b/ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs new file mode 100644 index 000000000..41341d616 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs @@ -0,0 +1,161 @@ +// 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.Collections.Generic; +using System.Composition; +using System.Linq; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class ContextMenuFrameworkTests +{ + [AvaloniaTest] + public void Build_Includes_Visible_Entries_With_Header_And_Skips_Hidden_Ones() + { + // ContextMenuProvider.Build is the pure-function core: it takes (entries, context) and + // returns a ContextMenu populated with one MenuItem per entry whose IsVisible(context) + // returns true. Entries whose IsVisible returns false are skipped entirely. + + // Arrange — three entries, one hidden. + var entries = new IContextMenuEntryExport[] { + Stub("Visible one", category: null, order: 0, visible: true, enabled: true), + Stub("Hidden", category: null, order: 1, visible: false, enabled: true), + Stub("Visible two", category: null, order: 2, visible: true, enabled: true), + }; + var context = new TextViewContext(); + + // Act — build the menu. + var menu = ContextMenuProvider.Build(entries, context); + + // Assert — only the two visible entries land, in declared order. + menu.Should().NotBeNull(); + var items = menu!.Items.OfType().ToList(); + items.Should().HaveCount(2); + items[0].Header.Should().Be("Visible one"); + items[1].Header.Should().Be("Visible two"); + } + + [AvaloniaTest] + public void Build_Marks_Entry_Disabled_When_Is_Enabled_Returns_False() + { + // Arrange — entry that's visible but disabled. + var entries = new IContextMenuEntryExport[] { + Stub("Disabled entry", category: null, order: 0, visible: true, enabled: false), + }; + var context = new TextViewContext(); + + // Act + var menu = ContextMenuProvider.Build(entries, context)!; + + // Assert — item is present but its IsEnabled flag is false; clicking it does nothing. + var item = menu.Items.OfType().Single(); + item.IsEnabled.Should().BeFalse(); + } + + [AvaloniaTest] + public void Build_Returns_Null_When_No_Visible_Entries_So_The_Menu_Is_Suppressed() + { + // An empty menu shouldn't be shown at all — the call site decides whether to suppress + // the right-click affordance based on a null return. + + // Arrange — single entry, hidden. + var entries = new IContextMenuEntryExport[] { + Stub("Hidden", category: null, order: 0, visible: false, enabled: true), + }; + + // Act + Assert + ContextMenuProvider.Build(entries, new TextViewContext()).Should().BeNull(); + } + + [AvaloniaTest] + public void Build_Inserts_Separators_Between_Categories() + { + // Arrange — two entries in category A, one in category B. + var entries = new IContextMenuEntryExport[] { + Stub("A1", category: "A", order: 0, visible: true, enabled: true), + Stub("A2", category: "A", order: 1, visible: true, enabled: true), + Stub("B1", category: "B", order: 2, visible: true, enabled: true), + }; + + // Act + var menu = ContextMenuProvider.Build(entries, new TextViewContext())!; + + // Assert — order is A1, A2, separator, B1. + var children = menu.Items.ToList(); + children.Should().HaveCount(4); + children[0].Should().BeOfType().Which.Header.Should().Be("A1"); + children[1].Should().BeOfType().Which.Header.Should().Be("A2"); + children[2].Should().BeOfType(); + children[3].Should().BeOfType().Which.Header.Should().Be("B1"); + } + + [AvaloniaTest] + public void Click_On_Menu_Item_Invokes_The_Entry_Execute_With_The_Context() + { + // Arrange — record the context the entry receives at Execute time. + TextViewContext? received = null; + var entry = new RecordingEntry(c => received = c); + var export = new StubExport( + entry, + new ContextMenuEntryMetadata { Header = "Run me", Order = 0 }); + var context = new TextViewContext(); + + // Act — build the menu, click the item. + var menu = ContextMenuProvider.Build(new[] { export }, context)!; + var item = menu.Items.OfType().Single(); + item.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); + + // Assert — Execute fired with the supplied context. + ReferenceEquals(received, context).Should().BeTrue(); + } + + static IContextMenuEntryExport Stub(string header, string? category, double order, bool visible, bool enabled) + => new StubExport( + new StubEntry(visible, enabled), + new ContextMenuEntryMetadata { Header = header, Category = category, Order = order }); + + sealed class StubEntry(bool visible, bool enabled) : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => visible; + public bool IsEnabled(TextViewContext context) => enabled; + public void Execute(TextViewContext context) { } + } + + 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/ContextMenuEntry.cs b/ILSpy/ContextMenuEntry.cs new file mode 100644 index 000000000..8f5664b31 --- /dev/null +++ b/ILSpy/ContextMenuEntry.cs @@ -0,0 +1,124 @@ +// 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 Avalonia; +using Avalonia.Controls; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.TextView; + +namespace ILSpy +{ + /// + /// MEF-exported entry that contributes a single item to a context menu. Exports are + /// discovered via and surfaced through + /// , which calls back into + /// (filters the menu), (greys the item out), and + /// (runs the action) with the live . + /// + public interface IContextMenuEntry + { + bool IsVisible(TextViewContext context); + bool IsEnabled(TextViewContext context); + void Execute(TextViewContext context); + } + + /// + /// Snapshot of the surface that opened a context menu (which control, the selection in it, + /// the reference under the pointer if any). Entries inspect the populated fields to decide + /// visibility / enabled state and to scope their action. + /// + public class TextViewContext + { + /// The selected nodes in the assembly-tree DataGrid, or null when the menu wasn't opened on the tree. + public SharpTreeNode[]? SelectedTreeNodes { get; init; } + + /// The assembly-tree the menu was opened on, or null otherwise. + public DataGrid? TreeGrid { get; init; } + + /// The decompiler text view the menu was opened on, or null otherwise. + public DecompilerTextView? TextView { get; init; } + + /// The list box (e.g. search results) the menu was opened on, or null otherwise. + public ListBox? ListBox { get; init; } + + /// The data grid (e.g. metadata table) the menu was opened on, or null otherwise. + public DataGrid? DataGrid { get; init; } + + /// The reference under the pointer in the text view, or the focused list/grid item, when applicable. + public ReferenceSegment? Reference { get; init; } + + /// The visual element the original event came from — useful for context-aware entries that need to inspect the click target. + public Visual? OriginalSource { get; init; } + } + + /// + /// Concrete metadata view for context-menu entries. Mirrors WPF's + /// IContextMenuEntryMetadata; requires a class + /// (not an interface) for metadata views. + /// + public class ContextMenuEntryMetadata + { + public string? MenuID { get; set; } + public string? ParentMenuID { get; set; } + public string? Icon { get; set; } + public string? Header { get; set; } + public string? Category { get; set; } + public string? InputGestureText { get; set; } + public double Order { get; set; } = double.MaxValue; + } + + /// + /// Marks a class as a context-menu entry discoverable through MEF. The metadata properties + /// drive where the entry appears in the menu hierarchy and what it looks like. + /// + [MetadataAttribute] + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public sealed class ExportContextMenuEntryAttribute : ExportAttribute + { + public ExportContextMenuEntryAttribute() + : base("ContextMenuEntry", typeof(IContextMenuEntry)) + { + // Entries default to end-of-menu unless explicitly ordered. + Order = double.MaxValue; + } + + /// + /// Optional ID that identifies this entry as a sub-menu parent. Other entries can set + /// to this value to nest under it. Avoid cycles + /// (MenuID == ParentMenuID); they would loop forever during menu construction. + /// + public string? MenuID { get; set; } + + /// + /// Optional ID of the parent sub-menu. places the entry at the + /// top level of the context menu. + /// + public string? ParentMenuID { get; set; } + + public string? Icon { get; set; } + public string? Header { get; set; } + public string? Category { get; set; } + public string? InputGestureText { get; set; } + public double Order { get; set; } + } +} diff --git a/ILSpy/ContextMenuProvider.cs b/ILSpy/ContextMenuProvider.cs new file mode 100644 index 000000000..de2e17d8a --- /dev/null +++ b/ILSpy/ContextMenuProvider.cs @@ -0,0 +1,164 @@ +// 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.Collections.Generic; +using System.Composition; +using System.Linq; + +using Avalonia.Controls; + +using ILSpy.AppEnv; + +namespace ILSpy +{ + /// + /// One discovered context-menu entry: the materialised + /// implementation paired with its metadata. Abstracts over System.Composition's + /// so call sites and tests can both feed entries + /// into without round-tripping through MEF. + /// + public interface IContextMenuEntryExport + { + IContextMenuEntry Value { get; } + ContextMenuEntryMetadata Metadata { get; } + } + + /// + /// MEF aggregator for -tagged entries. + /// Mirrors the MainMenuCommandRegistry pattern — XAML-instantiated views can + /// retrieve this via AppComposition.Current.GetExport<ContextMenuEntryRegistry>(). + /// + [Export] + [Shared] + public sealed class ContextMenuEntryRegistry + { + [ImportingConstructor] + public ContextMenuEntryRegistry( + [ImportMany("ContextMenuEntry")] IEnumerable> entries) + { + Entries = entries.Select(MefExport.From).ToArray(); + } + + public IReadOnlyList Entries { get; } + + // Adapter that exposes a System.Composition ExportFactory as the simpler + // IContextMenuEntryExport surface the provider consumes. CreateExport is invoked once + // at registry-construction time — we want stable, shared instances since each entry + // is exported as [Shared] anyway. + sealed class MefExport(ExportFactory factory) : IContextMenuEntryExport + { + readonly Lazy value = new(() => factory.CreateExport().Value); + + public IContextMenuEntry Value => value.Value; + public ContextMenuEntryMetadata Metadata { get; } = factory.Metadata; + + public static IContextMenuEntryExport From(ExportFactory factory) + => new MefExport(factory); + } + } + + /// + /// Pure-function builder: turns a sequence of context-menu entries into a populated + /// , given a . Entries hidden by + /// are dropped; entries disabled by + /// appear greyed-out. Categories are separated + /// by s; sub-menus are nested via the MenuID/ParentMenuID + /// metadata pair. Returns null when no entry would be visible — the caller can then + /// suppress the right-click affordance entirely. + /// + public static class ContextMenuProvider + { + public static ContextMenu? Build(IEnumerable entries, TextViewContext context) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(context); + + // Group by ParentMenuID so sub-menu entries are easy to nest. Sort each group by + // its declared Order; entries without an Order land at the end (double.MaxValue). + var groups = entries + .OrderBy(e => e.Metadata.Order) + .GroupBy(e => e.Metadata.ParentMenuID) + .ToDictionary(g => g.Key ?? string.Empty, g => g.ToArray()); + + var menu = new ContextMenu(); + BuildLevel(groups.GetValueOrDefault(string.Empty, Array.Empty()), menu.Items, groups, context); + return menu.Items.Count > 0 ? menu : null; + } + + static void BuildLevel( + IContextMenuEntryExport[] entries, + global::Avalonia.Controls.ItemCollection parent, + Dictionary groups, + TextViewContext context) + { + foreach (var category in entries.GroupBy(e => e.Metadata.Category)) + { + var needSeparator = parent.Count > 0; + foreach (var export in category) + { + var entry = export.Value; + if (!entry.IsVisible(context)) + continue; + if (needSeparator) + { + parent.Add(new Separator()); + needSeparator = false; + } + var item = new MenuItem { + Header = ResourceHelper.GetString(export.Metadata.Header) ?? export.Metadata.Header, + InputGesture = TryParseGesture(export.Metadata.InputGestureText), + HotKey = TryParseGesture(export.Metadata.InputGestureText), + }; + if (entry.IsEnabled(context)) + { + // Capture entry + context for the click handler — entries are stateless + // w.r.t. context, so the snapshot taken at Build-time is what runs. + item.Click += (_, _) => entry.Execute(context); + } + else + { + item.IsEnabled = false; + } + parent.Add(item); + + // Nested sub-menu — look up by MenuID and recurse into the item's own Items. + if (export.Metadata.MenuID is { Length: > 0 } menuId + && groups.TryGetValue(menuId, out var children)) + { + BuildLevel(children, item.Items, groups, context); + } + } + } + } + + static global::Avalonia.Input.KeyGesture? TryParseGesture(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + try + { + return global::Avalonia.Input.KeyGesture.Parse(text); + } + catch + { + return null; + } + } + } +}