mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
3 changed files with 449 additions and 0 deletions
@ -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; |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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<ContextMenuEntryRegistry>()</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…
Reference in new issue