Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
161cef4628
  1. 161
      ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs
  2. 124
      ILSpy/ContextMenuEntry.cs
  3. 164
      ILSpy/ContextMenuProvider.cs

161
ILSpy.Tests/ContextMenus/ContextMenuFrameworkTests.cs

@ -0,0 +1,161 @@ @@ -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<MenuItem>().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<MenuItem>().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<MenuItem>().Which.Header.Should().Be("A1");
children[1].Should().BeOfType<MenuItem>().Which.Header.Should().Be("A2");
children[2].Should().BeOfType<Separator>();
children[3].Should().BeOfType<MenuItem>().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<MenuItem>().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<TextViewContext> 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;
}
}

124
ILSpy/ContextMenuEntry.cs

@ -0,0 +1,124 @@ @@ -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
{
/// <summary>
/// MEF-exported entry that contributes a single item to a context menu. Exports are
/// discovered via <see cref="ExportContextMenuEntryAttribute"/> and surfaced through
/// <see cref="ContextMenuProvider.Build"/>, which calls back into <see cref="IsVisible"/>
/// (filters the menu), <see cref="IsEnabled"/> (greys the item out), and
/// <see cref="Execute"/> (runs the action) with the live <see cref="TextViewContext"/>.
/// </summary>
public interface IContextMenuEntry
{
bool IsVisible(TextViewContext context);
bool IsEnabled(TextViewContext context);
void Execute(TextViewContext context);
}
/// <summary>
/// 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.
/// </summary>
public class TextViewContext
{
/// <summary>The selected nodes in the assembly-tree DataGrid, or null when the menu wasn't opened on the tree.</summary>
public SharpTreeNode[]? SelectedTreeNodes { get; init; }
/// <summary>The assembly-tree <see cref="DataGrid"/> the menu was opened on, or null otherwise.</summary>
public DataGrid? TreeGrid { get; init; }
/// <summary>The decompiler text view the menu was opened on, or null otherwise.</summary>
public DecompilerTextView? TextView { get; init; }
/// <summary>The list box (e.g. search results) the menu was opened on, or null otherwise.</summary>
public ListBox? ListBox { get; init; }
/// <summary>The data grid (e.g. metadata table) the menu was opened on, or null otherwise.</summary>
public DataGrid? DataGrid { get; init; }
/// <summary>The reference under the pointer in the text view, or the focused list/grid item, when applicable.</summary>
public ReferenceSegment? Reference { get; init; }
/// <summary>The visual element the original event came from — useful for context-aware entries that need to inspect the click target.</summary>
public Visual? OriginalSource { get; init; }
}
/// <summary>
/// Concrete metadata view for context-menu entries. Mirrors WPF's
/// <c>IContextMenuEntryMetadata</c>; <see cref="System.Composition"/> requires a class
/// (not an interface) for metadata views.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <summary>
/// Optional ID that identifies this entry as a sub-menu parent. Other entries can set
/// <see cref="ParentMenuID"/> to this value to nest under it. Avoid cycles
/// (<c>MenuID == ParentMenuID</c>); they would loop forever during menu construction.
/// </summary>
public string? MenuID { get; set; }
/// <summary>
/// Optional ID of the parent sub-menu. <see langword="null"/> places the entry at the
/// top level of the context menu.
/// </summary>
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; }
}
}

164
ILSpy/ContextMenuProvider.cs

@ -0,0 +1,164 @@ @@ -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
{
/// <summary>
/// One discovered context-menu entry: the materialised <see cref="IContextMenuEntry"/>
/// implementation paired with its metadata. Abstracts over <c>System.Composition</c>'s
/// <see cref="ExportFactory{T, TMetadata}"/> so call sites and tests can both feed entries
/// into <see cref="ContextMenuProvider.Build"/> without round-tripping through MEF.
/// </summary>
public interface IContextMenuEntryExport
{
IContextMenuEntry Value { get; }
ContextMenuEntryMetadata Metadata { get; }
}
/// <summary>
/// MEF aggregator for <see cref="ExportContextMenuEntryAttribute"/>-tagged entries.
/// Mirrors the <c>MainMenuCommandRegistry</c> pattern — XAML-instantiated views can
/// retrieve this via <c>AppComposition.Current.GetExport&lt;ContextMenuEntryRegistry&gt;()</c>.
/// </summary>
[Export]
[Shared]
public sealed class ContextMenuEntryRegistry
{
[ImportingConstructor]
public ContextMenuEntryRegistry(
[ImportMany("ContextMenuEntry")] IEnumerable<ExportFactory<IContextMenuEntry, ContextMenuEntryMetadata>> entries)
{
Entries = entries.Select(MefExport.From).ToArray();
}
public IReadOnlyList<IContextMenuEntryExport> 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<IContextMenuEntry, ContextMenuEntryMetadata> factory) : IContextMenuEntryExport
{
readonly Lazy<IContextMenuEntry> value = new(() => factory.CreateExport().Value);
public IContextMenuEntry Value => value.Value;
public ContextMenuEntryMetadata Metadata { get; } = factory.Metadata;
public static IContextMenuEntryExport From(ExportFactory<IContextMenuEntry, ContextMenuEntryMetadata> factory)
=> new MefExport(factory);
}
}
/// <summary>
/// Pure-function builder: turns a sequence of context-menu entries into a populated
/// <see cref="ContextMenu"/>, given a <see cref="TextViewContext"/>. Entries hidden by
/// <see cref="IContextMenuEntry.IsVisible"/> are dropped; entries disabled by
/// <see cref="IContextMenuEntry.IsEnabled"/> appear greyed-out. Categories are separated
/// by <see cref="Separator"/>s; sub-menus are nested via the <c>MenuID</c>/<c>ParentMenuID</c>
/// metadata pair. Returns null when no entry would be visible — the caller can then
/// suppress the right-click affordance entirely.
/// </summary>
public static class ContextMenuProvider
{
public static ContextMenu? Build(IEnumerable<IContextMenuEntryExport> 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<IContextMenuEntryExport>()), menu.Items, groups, context);
return menu.Items.Count > 0 ? menu : null;
}
static void BuildLevel(
IContextMenuEntryExport[] entries,
global::Avalonia.Controls.ItemCollection parent,
Dictionary<string, IContextMenuEntryExport[]> 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;
}
}
}
}
Loading…
Cancel
Save