From f679c88a165561498e73013e64722e352c43dd8a Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Thu, 9 Jan 2025 16:05:02 -0600 Subject: [PATCH] Implement Allowing CommandProviders to ItemsChanged themselves (#289) This is the implementation of what was spec'd in #282. When we get an `ItemsChanged`, we'll look through the top level commands, slice out all the old ones, then fetch the new ones and stitch them in the same place in the list. I've only implemented it for the Bookmarks provider so far. Closes #277 --- .../BookmarksCommandProvider.cs | 3 +- .../CommandProviderWrapper.cs | 25 ++++++ .../TopLevelCommandManager.cs | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) 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.