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 @@ |
|||||||
|
// 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 @@ |
|||||||
|
// 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 @@ |
|||||||
|
// 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