diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs index 0cbe609e45..d6b24f1ece 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs +++ b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs @@ -27,8 +27,9 @@ public partial class BookmarksCommandProvider : CommandProvider private void AddNewCommand_AddedAction(object sender, object? args) { - _addNewCommand.AddedAction += AddNewCommand_AddedAction; _commands.Clear(); + LoadCommands(); + RaiseItemsChanged(0); } private void LoadCommands() diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 1a416eacb1..e221e25eea 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -4,6 +4,7 @@ using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Extensions; +using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; @@ -21,6 +22,8 @@ public sealed class CommandProviderWrapper public IFallbackCommandItem[] FallbackItems { get; private set; } = []; + public event TypedEventHandler? CommandsChanged; + public string Id { get; private set; } = string.Empty; public string DisplayName { get; private set; } = string.Empty; @@ -34,6 +37,8 @@ public sealed class CommandProviderWrapper public CommandProviderWrapper(ICommandProvider provider) { _commandProvider = provider; + _commandProvider.ItemsChanged += CommandProvider_ItemsChanged; + isValid = true; Id = provider.Id; DisplayName = provider.DisplayName; @@ -56,6 +61,15 @@ public sealed class CommandProviderWrapper } _commandProvider = provider; + + try + { + _commandProvider.ItemsChanged += CommandProvider_ItemsChanged; + } + catch + { + } + isValid = true; } @@ -106,4 +120,15 @@ public sealed class CommandProviderWrapper public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid; public override int GetHashCode() => _commandProvider.GetHashCode(); + + private void CommandProvider_ItemsChanged(object sender, ItemsChangedEventArgs args) + { + // We don't want to handle this ourselves - we want the + // TopLevelCommandManager to know about this, so they can remove + // our old commands from their own list. + // + // In handling this, a call will be made to `LoadTopLevelCommands` to + // retrieve the new items. + this.CommandsChanged?.Invoke(this, args); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 47e0da0fec..d0b6cfd5e7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.CmdPal.UI.ViewModels; @@ -53,6 +54,88 @@ public partial class TopLevelCommandManager(IServiceProvider _serviceProvider) : { TopLevelCommands.Add(new(new(i), true)); } + + commandProvider.CommandsChanged += CommandProvider_CommandsChanged; + } + + private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, ItemsChangedEventArgs args) + { + // By all accounts, we're already on a background thread (the COM call + // to handle the event shouldn't be on the main thread.). But just to + // be sure we don't block the caller, hop off this thread + _ = Task.Run(async () => await UpdateCommandsForProvider(sender, args)); + } + + /// + /// Called when a command provider raises its ItemsChanged event. We'll + /// remove the old commands from the top-level list and try to put the new + /// ones in the same place in the list. + /// + /// The provider who's commands changed + /// the ItemsChangedEvent the provider raised + /// an awaitable task + private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, ItemsChangedEventArgs args) + { + // Work on a clone of the list, so that we can just do one atomic + // update to the actual observable list at the end + List clone = [.. TopLevelCommands]; + List newItems = []; + var startIndex = -1; + var firstCommand = sender.TopLevelItems[0]; + var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length; + + // Tricky: all Commands from a single provider get added to the + // top-level list all together, in a row. So if we find just the first + // one, we can slice it out and insert the new ones there. + for (var i = 0; i < clone.Count; i++) + { + var wrapper = clone[i]; + try + { + var thisCommand = wrapper.Model.Unsafe; + if (thisCommand != null) + { + var isTheSame = thisCommand == firstCommand; + if (isTheSame) + { + startIndex = i; + break; + } + } + } + catch + { + } + } + + // Fetch the new items + await sender.LoadTopLevelCommands(); + foreach (var i in sender.TopLevelItems) + { + newItems.Add(new(new(i), false)); + } + + foreach (var i in sender.FallbackItems) + { + newItems.Add(new(new(i), true)); + } + + // Slice out the old commands + if (startIndex != -1) + { + clone.RemoveRange(startIndex, commandsToRemove); + } + else + { + // ... or, just stick them at the end (this is unexpected) + startIndex = clone.Count; + } + + // add the new commands into the list at the place we found the old ones + clone.InsertRange(startIndex, newItems); + + // now update the actual observable list with the new contents + ListHelpers.InPlaceUpdateList(TopLevelCommands, clone); } // Load commands from our extensions.