mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Avalonia has no global ICommand re-query signal like WPF's CommandManager.RequerySuggested, so state-dependent main-menu commands were frozen at their startup CanExecute: a NativeMenuItem only re-reads CanExecute when the command raises CanExecuteChanged, and nothing did. The assembly list is empty when the menu is built, so Clear assembly list was disabled forever; Save and Remove-assemblies-with-load-errors had the same latent bug. Add a weak global CommandManager (held weakly so menu/toolbar items aren't pinned) that SimpleCommand routes CanExecuteChanged through -- exactly as WPF's SimpleCommand routed it -- and invalidate it at the central state-change points: tree selection, the active assembly list, and background-load-sweep completion (so load errors surface). One signal, every platform: NativeMenuItem maps CanExecute onto IsEnabled, which the macOS Cocoa, Linux DBus, and in-window menu exporters all track. Assisted-by: Claude:claude-opus-4-8:Claude Codepull/3755/head
5 changed files with 293 additions and 8 deletions
@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
|
||||
// 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.Threading.Tasks; |
||||
|
||||
using Avalonia.Headless.NUnit; |
||||
using Avalonia.Threading; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using ICSharpCode.ILSpy.Properties; |
||||
|
||||
using ILSpy.AppEnv; |
||||
using ILSpy.Commands; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests; |
||||
|
||||
[TestFixture] |
||||
public class ClearAssemblyListCommandTests |
||||
{ |
||||
[AvaloniaTest] |
||||
public async Task Clear_Assembly_List_Re_Raises_CanExecute_When_The_List_Changes() |
||||
{ |
||||
// The native menu only re-reads CanExecute when CanExecuteChanged fires. The command used to
|
||||
// never raise it, so it stayed at its startup value -- the list was empty then, leaving the
|
||||
// menu item disabled forever. Now CanExecuteChanged is routed through the shared CommandManager,
|
||||
// which the assembly-list change invalidates -- so the item re-queries whenever the list changes.
|
||||
var (_, vm) = await TestHarness.BootAsync(1); |
||||
var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>() |
||||
.GetCommand(nameof(Resources.ClearAssemblyList)); |
||||
|
||||
command.CanExecute(null).Should().BeTrue("with assemblies loaded the command is enabled"); |
||||
|
||||
bool raised = false; |
||||
// Keep the handler rooted: CommandManager holds subscribers weakly, so an inline lambda with
|
||||
// no other reference could be collected before the raise.
|
||||
EventHandler handler = (_, _) => raised = true; |
||||
command.CanExecuteChanged += handler; |
||||
|
||||
vm.AssemblyTreeModel.AssemblyList!.Clear(); |
||||
Dispatcher.UIThread.RunJobs(); |
||||
|
||||
raised.Should().BeTrue( |
||||
"clearing the list must re-raise CanExecuteChanged so the menu re-queries the command's enablement"); |
||||
command.CanExecute(null).Should().BeFalse("an empty list disables the command"); |
||||
GC.KeepAlive(handler); |
||||
} |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
// 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.Threading.Tasks; |
||||
|
||||
using Avalonia.Headless.NUnit; |
||||
using Avalonia.Threading; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using ICSharpCode.ILSpy.Properties; |
||||
|
||||
using ILSpy.AppEnv; |
||||
using ILSpy.Commands; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests; |
||||
|
||||
/// <summary>
|
||||
/// The general fix (routing SimpleCommand.CanExecuteChanged through the shared CommandManager,
|
||||
/// invalidated on selection changes) must apply to every selection-dependent menu command, not just
|
||||
/// the one originally reported. Save Code is the canonical case.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class SaveCommandEnablementTests |
||||
{ |
||||
[AvaloniaTest] |
||||
public async Task Save_Command_Enables_When_A_Tree_Node_Is_Selected() |
||||
{ |
||||
var (_, vm) = await TestHarness.BootAsync(1); |
||||
var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>() |
||||
.GetCommand(nameof(Resources._SaveCode)); |
||||
|
||||
command.CanExecute(null).Should().BeFalse("with nothing selected at startup, Save is disabled"); |
||||
|
||||
bool raised = false; |
||||
EventHandler handler = (_, _) => raised = true; // rooted; CommandManager holds handlers weakly
|
||||
command.CanExecuteChanged += handler; |
||||
|
||||
vm.AssemblyTreeModel.SelectNode(vm.AssemblyTreeModel.Root!.Children[0]); |
||||
Dispatcher.UIThread.RunJobs(); |
||||
|
||||
raised.Should().BeTrue( |
||||
"selecting a node must re-raise CanExecuteChanged so the menu re-queries the command's enablement"); |
||||
command.CanExecute(null).Should().BeTrue("a selected tree node enables Save"); |
||||
GC.KeepAlive(handler); |
||||
} |
||||
} |
||||
@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
|
||||
// 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.Reflection; |
||||
|
||||
namespace ILSpy.Commands |
||||
{ |
||||
/// <summary>
|
||||
/// A poor-man's equivalent of WPF's <c>CommandManager.RequerySuggested</c>. Avalonia has no
|
||||
/// global "re-query your CanExecute" signal, so <see cref="SimpleCommand"/> routes its
|
||||
/// <c>CanExecuteChanged</c> through this one (exactly as WPF's SimpleCommand routed it through
|
||||
/// <c>CommandManager.RequerySuggested</c>). Call <see cref="InvalidateRequerySuggested"/> whenever
|
||||
/// application state that a command's <c>CanExecute</c> reads might have changed (tree selection,
|
||||
/// the assembly list, a background load finishing); every bound command then re-evaluates.
|
||||
///
|
||||
/// This works on every platform: a bound <c>NativeMenuItem</c> subscribes to the command's
|
||||
/// CanExecuteChanged and, when it fires, sets <c>IsEnabled = CanExecute()</c>; the macOS Cocoa,
|
||||
/// Linux DBus, and in-window menu exporters all track that <c>IsEnabled</c> property.
|
||||
///
|
||||
/// Subscribers are held WEAKLY (by target + method, like <see cref="Util.WeakEventSource{T}"/>),
|
||||
/// so a rebuilt menu or toolbar -- whose items subscribe via the routed CanExecuteChanged -- is
|
||||
/// not pinned for the lifetime of this static type. NOTE: a handler with no other strong reference
|
||||
/// (e.g. an inline lambda) may be collected before the next raise; callers that need it to survive
|
||||
/// must keep it rooted, just as with WPF's weak RequerySuggested.
|
||||
/// </summary>
|
||||
public static class CommandManager |
||||
{ |
||||
static readonly object gate = new(); |
||||
static readonly List<WeakHandler> handlers = new(); |
||||
|
||||
/// <summary>Subscribes <paramref name="handler"/> (weakly) to the global re-query signal.</summary>
|
||||
public static void AddRequerySuggested(EventHandler handler) |
||||
{ |
||||
ArgumentNullException.ThrowIfNull(handler); |
||||
lock (gate) |
||||
handlers.Add(new WeakHandler(handler)); |
||||
} |
||||
|
||||
/// <summary>Removes a previously added handler.</summary>
|
||||
public static void RemoveRequerySuggested(EventHandler handler) |
||||
{ |
||||
ArgumentNullException.ThrowIfNull(handler); |
||||
lock (gate) |
||||
{ |
||||
for (int i = handlers.Count - 1; i >= 0; i--) |
||||
{ |
||||
if (handlers[i].Matches(handler)) |
||||
{ |
||||
handlers.RemoveAt(i); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Asks every bound command to re-evaluate its <c>CanExecute</c>. Marshalled to the UI thread
|
||||
/// (handlers touch UI state such as <c>NativeMenuItem.IsEnabled</c>), so it is safe to call from
|
||||
/// a background thread (e.g. a load sweep finishing off the thread pool).
|
||||
/// </summary>
|
||||
public static void InvalidateRequerySuggested() |
||||
{ |
||||
var dispatcher = global::Avalonia.Threading.Dispatcher.UIThread; |
||||
if (dispatcher.CheckAccess()) |
||||
Raise(); |
||||
else |
||||
dispatcher.Post(Raise); |
||||
} |
||||
|
||||
static void Raise() |
||||
{ |
||||
// Snapshot under lock so add/remove during dispatch doesn't perturb this raise.
|
||||
WeakHandler[] snapshot; |
||||
lock (gate) |
||||
snapshot = handlers.ToArray(); |
||||
List<WeakHandler>? dead = null; |
||||
foreach (var h in snapshot) |
||||
{ |
||||
if (!h.TryInvoke()) |
||||
(dead ??= new()).Add(h); |
||||
} |
||||
if (dead != null) |
||||
{ |
||||
lock (gate) |
||||
foreach (var h in dead) |
||||
handlers.Remove(h); |
||||
} |
||||
} |
||||
|
||||
sealed class WeakHandler |
||||
{ |
||||
readonly WeakReference<object>? targetRef; |
||||
readonly MethodInfo method; |
||||
|
||||
public WeakHandler(EventHandler handler) |
||||
{ |
||||
method = handler.Method; |
||||
// Static handlers (null Target) are kept until Remove drops them explicitly.
|
||||
targetRef = handler.Target is null ? null : new WeakReference<object>(handler.Target); |
||||
} |
||||
|
||||
public bool Matches(EventHandler handler) |
||||
{ |
||||
if (method != handler.Method) |
||||
return false; |
||||
if (targetRef == null) |
||||
return handler.Target == null; |
||||
return targetRef.TryGetTarget(out var t) && ReferenceEquals(t, handler.Target); |
||||
} |
||||
|
||||
public bool TryInvoke() |
||||
{ |
||||
object? target; |
||||
if (targetRef == null) |
||||
target = null; |
||||
else if (!targetRef.TryGetTarget(out target!)) |
||||
return false; // collected -> caller prunes
|
||||
method.Invoke(target, new object?[] { null, EventArgs.Empty }); |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue