diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 939b42de14..d0f73f4a12 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -6,6 +6,7 @@ using ManagedCommon; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper private readonly TaskScheduler _taskScheduler; + private readonly ICommandProviderCache? _commandProviderCache; + public TopLevelViewModel[] TopLevelItems { get; private set; } = []; public TopLevelViewModel[] FallbackItems { get; private set; } = []; @@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper public bool IsActive { get; private set; } - public string ProviderId - { - get - { - return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; - } - } + public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread) { @@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper Logger.LogDebug($"Initialized command provider {ProviderId}"); } - public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread) + public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache) { _taskScheduler = mainThread; + _commandProviderCache = commandProviderCache; + Extension = extension; ExtensionHost = new CommandPaletteHost(extension); if (!Extension.IsRunning()) @@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper if (!isValid) { IsActive = false; + RecallFromCache(); return; } var settings = serviceProvider.GetService()!; - IsActive = GetProviderSettings(settings).IsEnabled; + var providerSettings = GetProviderSettings(settings); + IsActive = providerSettings.IsEnabled; if (!IsActive) { + RecallFromCache(); return; } - ICommandItem[]? commands = null; - IFallbackCommandItem[]? fallbacks = null; - + var displayInfoInitialized = false; try { var model = _commandProvider.Unsafe!; - Task t = new(model.TopLevelCommands); - t.Start(); - commands = await t.ConfigureAwait(false); + Task loadTopLevelCommandsTask = new(model.TopLevelCommands); + loadTopLevelCommandsTask.Start(); + var commands = await loadTopLevelCommandsTask.ConfigureAwait(false); // On a BG thread here - fallbacks = model.FallbackCommands(); + var fallbacks = model.FallbackCommands(); if (model is ICommandProvider2 two) { @@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper DisplayName = model.DisplayName; Icon = new(model.Icon); Icon.InitializeProperties(); + displayInfoInitialized = true; + + // Update cached display name + if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null) + { + _commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName)); + } // Note: explicitly not InitializeProperties()ing the settings here. If // we do that, then we'd regress GH #38321 @@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper Logger.LogError("Failed to load commands from extension"); Logger.LogError($"Extension was {Extension!.PackageFamilyName}"); Logger.LogError(e.ToString()); + + if (!displayInfoInitialized) + { + RecallFromCache(); + } + } + } + + private void RecallFromCache() + { + var cached = _commandProviderCache?.Recall(ProviderId); + if (cached is not null) + { + DisplayName = cached.DisplayName; + } + + if (string.IsNullOrWhiteSpace(DisplayName)) + { + DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId; } } @@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper var settings = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); - Func makeAndAdd = (ICommandItem? i, bool fallback) => + var makeAndAdd = (ICommandItem? i, bool fallback) => { CommandItemViewModel commandItemViewModel = new(new(i), pageContext); TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 68e554e463..d6208b712a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class ProviderSettingsViewModel : ObservableObject { + private static readonly IconInfoViewModel EmptyIcon = new(null); + private readonly CommandProviderWrapper _provider; private readonly ProviderSettings _providerSettings; private readonly SettingsModel _settings; - private readonly Lock _initializeSettingsLock = new(); + private Task? _initializeSettingsTask; public ProviderSettingsViewModel( @@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject HasFallbackCommands ? $"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" : $"{ExtensionName}, {TopLevelCommands.Count} commands" : - Resources.builtin_disabled_extension; + $"{ExtensionName}, {Resources.builtin_disabled_extension}"; [MemberNotNullWhen(true, nameof(Extension))] public bool IsFromExtension => _provider.Extension is not null; @@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty; - public IconInfoViewModel Icon => _provider.Icon; + public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon; [ObservableProperty] public partial bool LoadingSettings { get; set; } @@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject WeakReferenceMessenger.Default.Send(new()); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ExtensionSubtext)); + OnPropertyChanged(nameof(Icon)); } if (value == true) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs new file mode 100644 index 0000000000..0ab835ebb9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +internal sealed class CommandProviderCacheContainer +{ + public Dictionary Cache { get; init; } = new(StringComparer.Ordinal); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs new file mode 100644 index 0000000000..8c86c28178 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public record CommandProviderCacheItem(string DisplayName); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs new file mode 100644 index 0000000000..9b73cdd19d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +[JsonSerializable(typeof(CommandProviderCacheItem))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(CommandProviderCacheContainer))] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)] +internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs new file mode 100644 index 0000000000..5e8f790b12 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable +{ + private const string CacheFileName = "commandProviderCache.json"; + + private readonly Dictionary _cache = new(StringComparer.Ordinal); + + private readonly Lock _sync = new(); + + private readonly SupersedingAsyncGate _saveGate; + + public DefaultCommandProviderCache() + { + _saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false)); + TryLoad(); + } + + public void Memorize(string providerId, CommandProviderCacheItem item) + { + ArgumentNullException.ThrowIfNull(providerId); + + lock (_sync) + { + _cache[providerId] = item; + } + + _ = _saveGate.ExecuteAsync(); + } + + public CommandProviderCacheItem? Recall(string providerId) + { + ArgumentNullException.ThrowIfNull(providerId); + + lock (_sync) + { + _cache.TryGetValue(providerId, out var item); + return item; + } + } + + private static string GetCacheFilePath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, CacheFileName); + } + + private void TryLoad() + { + try + { + var path = GetCacheFilePath(); + if (!File.Exists(path)) + { + return; + } + + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + { + return; + } + + var loaded = JsonSerializer.Deserialize( + json, + CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!); + if (loaded?.Cache is null) + { + return; + } + + _cache.Clear(); + foreach (var kvp in loaded.Cache) + { + if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null) + { + _cache[kvp.Key] = kvp.Value; + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to load command provider cache: ", ex); + } + } + + private async Task TrySaveAsync() + { + try + { + Dictionary snapshot; + lock (_sync) + { + snapshot = new Dictionary(_cache, StringComparer.Ordinal); + } + + var container = new CommandProviderCacheContainer + { + Cache = snapshot, + }; + + var path = GetCacheFilePath(); + var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!); + await File.WriteAllTextAsync(path, json).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError("Failed to save command provider cache: ", ex); + } + } + + public void Dispose() + { + _saveGate.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs new file mode 100644 index 0000000000..201e6baa0e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public interface ICommandProviderCache +{ + void Memorize(string providerId, CommandProviderCacheItem item); + + CommandProviderCacheItem? Recall(string providerId); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index c113b508d3..4473a1e144 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject, IDisposable { private readonly IServiceProvider _serviceProvider; + private readonly ICommandProviderCache _commandProviderCache; private readonly TaskScheduler _taskScheduler; private readonly List _builtInCommands = []; @@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject, TaskScheduler IPageContext.Scheduler => _taskScheduler; - public TopLevelCommandManager(IServiceProvider serviceProvider) + public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) { _serviceProvider = serviceProvider; + _commandProviderCache = commandProviderCache; _taskScheduler = _serviceProvider.GetService()!; WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); @@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject, try { await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10)); - return new CommandProviderWrapper(extension, _taskScheduler); + return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache); } catch (Exception ex) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 09af333bbe..3d6a7ef634 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -178,6 +178,7 @@ public partial class App : Application, IDisposable services.AddSingleton(state); // Services + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png new file mode 100644 index 0000000000..cf1cd5c9b6 Binary files /dev/null and b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png differ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index af529a1bab..528e8d2351 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -229,12 +229,26 @@ - + TargetType="x:Boolean" + Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}"> + + + + + + +