diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionOrderHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionOrderHelper.cs new file mode 100644 index 000000000000..124d57751dc8 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionOrderHelper.cs @@ -0,0 +1,50 @@ +// 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; + +/// +/// Pure helper methods for sorting items according to a user-defined extension +/// order. Operates on provider ID strings so the logic is easily testable without +/// constructing heavyweight view-model instances. +/// +internal static class ExtensionOrderHelper +{ + /// + /// Returns a new list with items sorted so that those whose provider is in + /// appear first (in that order), followed by + /// the remaining items in their original order. The sort is stable: items that + /// share a provider (and therefore the same order index) keep their original + /// relative order, so a single provider's commands are never shuffled. + /// + internal static List SortByExtensionOrder(List items, string[] extensionOrder, Func providerIdSelector) + { + var orderLookup = new Dictionary(extensionOrder.Length, StringComparer.Ordinal); + for (var i = 0; i < extensionOrder.Length; i++) + { + orderLookup.TryAdd(extensionOrder[i], i); + } + + var ordered = new List(items.Count); + var unordered = new List(items.Count); + + foreach (var item in items) + { + if (orderLookup.ContainsKey(providerIdSelector(item))) + { + ordered.Add(item); + } + else + { + unordered.Add(item); + } + } + + // OrderBy is a stable sort, so commands from the same provider keep the + // relative order in which they were loaded. + var result = ordered.OrderBy(item => orderLookup[providerIdSelector(item)]).ToList(); + result.AddRange(unordered); + return result; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index cf3780e04f1e..1ec99699b828 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -67,6 +67,14 @@ public string[] FallbackRanks init => _fallbackRanks = value; } + private string[]? _extensionOrder = []; + + public string[] ExtensionOrder + { + get => _extensionOrder ?? []; + init => _extensionOrder = value; + } + private ImmutableDictionary? _aliases = ImmutableDictionary.Empty; @@ -165,12 +173,14 @@ public SettingsModel( ImmutableList? pinnedCommands = null, ImmutableDictionary? providerSettings = null, string[]? fallbackRanks = null, + string[]? extensionOrder = null, ImmutableDictionary? aliases = null, ImmutableList? commandHotkeys = null) { PinnedCommands = pinnedCommands ?? ImmutableList.Empty; ProviderSettings = providerSettings ?? ImmutableDictionary.Empty; FallbackRanks = fallbackRanks ?? []; + ExtensionOrder = extensionOrder ?? []; Aliases = aliases ?? ImmutableDictionary.Empty; CommandHotkeys = commandHotkeys ?? ImmutableList.Empty; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 50256d077eb9..a5aa75bc43de 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -297,6 +297,8 @@ public bool EnableDock public ObservableCollection FallbackRankings { get; set; } = new(); + public ObservableCollection ExtensionOrderRankings { get; set; } = new(); + public ObservableCollection MonitorConfigs { get; } = new(); public SettingsExtensionsViewModel Extensions { get; } @@ -366,6 +368,24 @@ public SettingsViewModel( } FallbackRankings = new ObservableCollection(fallbackRankings.OrderBy(o => o.Score).Select(fr => fr.Item)); + + // Build extension order rankings: providers in ExtensionOrder first (in order), then the rest + var currentExtensionOrder = _settingsService.Settings.ExtensionOrder; + var extensionOrderLookup = new Dictionary(currentExtensionOrder.Length, StringComparer.Ordinal); + for (var i = 0; i < currentExtensionOrder.Length; i++) + { + extensionOrderLookup.TryAdd(currentExtensionOrder[i], i); + } + + var orderedProviders = new List>(CommandProviders.Count); + foreach (var provider in CommandProviders) + { + var score = extensionOrderLookup.TryGetValue(provider.Id, out var idx) ? idx : CommandProviders.Count + orderedProviders.Count; + orderedProviders.Add(new Scored() { Item = provider, Score = score }); + } + + ExtensionOrderRankings = new ObservableCollection(orderedProviders.OrderBy(o => o.Score).Select(o => o.Item)); + Extensions = new SettingsExtensionsViewModel(CommandProviders, scheduler); if (needsSave) @@ -393,6 +413,13 @@ public void ApplyFallbackSort() PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings))); } + public void ApplyExtensionOrder() + { + _settingsService.UpdateSettings(s => s with { ExtensionOrder = ExtensionOrderRankings.Select(p => p.Id).ToArray() }); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ExtensionOrderRankings))); + WeakReferenceMessenger.Default.Send(); + } + /// /// Builds or refreshes the collection by reconciling /// connected monitors with persisted per-monitor settings. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 5fdc8c48f0a7..41ac04ddc44b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -191,9 +191,13 @@ private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IIte var startIndex = FindIndexForFirstProviderItem(clone, sender.ProviderId); clone.RemoveAll(item => item.CommandProviderId == sender.ProviderId); - clone.InsertRange(startIndex, newItems); - ListHelpers.InPlaceUpdateList(TopLevelCommands, clone); + // Insert the refreshed items where this provider previously sat (or at the + // end if it's brand new). RebuildTopLevelCommands then re-sorts the whole + // list so the provider lands in its user-configured position. + clone.InsertRange(Math.Min(startIndex, clone.Count), newItems); + + RebuildTopLevelCommands(clone); } lock (_dockBandsLock) @@ -469,10 +473,14 @@ private async Task RegisterAndLoadCommandsAsync(IEnumera lock (TopLevelCommands) { - foreach (var c in commandsToAdd) - { - TopLevelCommands.Add(c); - } + // Append the freshly loaded batch, then re-sort the entire top-level list + // so the user's configured extension order is honored across batches + // (built-ins and external extensions load in separate batches). + var clone = new List(TopLevelCommands.Count + commandsToAdd.Count); + clone.AddRange(TopLevelCommands); + clone.AddRange(commandsToAdd); + + RebuildTopLevelCommands(clone); } lock (_dockBandsLock) @@ -495,6 +503,23 @@ private async Task RegisterAndLoadCommandsAsync(IEnumera return new RegisterAndLoadSummary(totalCommands, totalDockBands); } + /// + /// Replaces the contents of with , + /// first applying the user-configured extension order when one is set. Callers must hold the + /// lock. Built-in and external providers load in separate + /// batches, so re-sorting the whole list here is what lets the configured order span them. + /// + private void RebuildTopLevelCommands(List newCommands) + { + var extensionOrder = _serviceProvider.GetRequiredService().Settings.ExtensionOrder; + if (extensionOrder.Length > 0) + { + newCommands = ExtensionOrderHelper.SortByExtensionOrder(newCommands, extensionOrder, c => c.CommandProviderId); + } + + ListHelpers.InPlaceUpdateList(TopLevelCommands, newCommands); + } + private async Task TryLoadCommandsAsync(CommandProviderWrapper wrapper, CancellationToken ct) { var sw = Stopwatch.StartNew(); @@ -539,10 +564,11 @@ private async Task AppendCommandsWhenReadyAsync( { lock (TopLevelCommands) { - foreach (var c in commands) - { - TopLevelCommands.Add(c); - } + var clone = new List(TopLevelCommands.Count + commands.Count); + clone.AddRange(TopLevelCommands); + clone.AddRange(commands); + + RebuildTopLevelCommands(clone); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml new file mode 100644 index 000000000000..42d6dc122e5a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml.cs new file mode 100644 index 000000000000..a5a4cc02a125 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRanker.xaml.cs @@ -0,0 +1,31 @@ +// 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 Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ExtensionRanker : UserControl +{ + private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + private SettingsViewModel? viewModel; + + public ExtensionRanker() + { + this.InitializeComponent(); + + var topLevelCommandManager = App.Current.Services.GetService()!; + var themeService = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetRequiredService(); + viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); + } + + private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) + { + viewModel?.ApplyExtensionOrder(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml new file mode 100644 index 000000000000..a1b2face91cd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml @@ -0,0 +1,43 @@ + + + + + + + + 800 + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml.cs new file mode 100644 index 000000000000..59d177d48807 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ExtensionRankerDialog.xaml.cs @@ -0,0 +1,21 @@ +// 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 Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ExtensionRankerDialog : UserControl +{ + public ExtensionRankerDialog() + { + InitializeComponent(); + } + + public IAsyncOperation ShowAsync() + { + return ExtensionRankerContentDialog!.ShowAsync()!; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index c8b11a88586d..4b21bc940ec2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -178,6 +178,11 @@ + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs index 2fb660db3d32..4a91687f4482 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs @@ -93,4 +93,16 @@ private async void MenuFlyoutItem_OnClick(object sender, RoutedEventArgs e) Logger.LogError("Error when showing FallbackRankerDialog", ex); } } + + private async void ReorderExtensions_OnClick(object sender, RoutedEventArgs e) + { + try + { + await ExtensionRankerDialog!.ShowAsync(); + } + catch (Exception ex) + { + Logger.LogError("Error when showing ExtensionRankerDialog", ex); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 1e7c91d3cddb..442f03faa7f2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -823,6 +823,15 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Manage fallback order + + Manage extension order + + + Manage extension order + + + Drag items to set which extensions appear first in the top-level list; extensions at the top take priority. + Version {0} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionOrderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionOrderTests.cs new file mode 100644 index 000000000000..6c2f1634d294 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionOrderTests.cs @@ -0,0 +1,198 @@ +// 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.Collections.Generic; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public class ExtensionOrderTests +{ + private static readonly string[] OrderAB = ["a", "b"]; + private static readonly string[] OrderABC = ["a", "b", "c"]; + private static readonly string[] ExpectedXYZ = ["x", "y", "z"]; + private static readonly string[] ExpectedABC = ["a", "b", "c"]; + private static readonly string[] ExpectedABXY = ["a", "b", "x", "y"]; + private static readonly string[] OrderExternalThenBuiltIns = ["external.foo", "builtin.apps", "builtin.calc"]; + private static readonly string[] OrderExternalThenApps = ["external.foo", "builtin.apps"]; + private static readonly string[] ExpectedExternalFirst = ["external.foo", "builtin.apps", "builtin.calc"]; + private static readonly string[] ExpectedNewProviderLast = ["external.foo", "builtin.apps", "newly.installed"]; + + [TestMethod] + public void ExtensionOrder_DefaultIsEmpty() + { + var settings = DeserializeSettings("{}"); + Assert.IsNotNull(settings.ExtensionOrder); + Assert.AreEqual(0, settings.ExtensionOrder.Length); + } + + [TestMethod] + public void ExtensionOrder_RoundTrips() + { + var order = new[] { "provider.b", "provider.a", "provider.c" }; + var settings = DeserializeSettings("{}") with { ExtensionOrder = order }; + + var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.SettingsModel); + var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!; + + CollectionAssert.AreEqual(order, deserialized.ExtensionOrder); + } + + [TestMethod] + public void ExtensionOrder_NullDeserializesToEmpty() + { + var json = """{"ExtensionOrder": null}"""; + var settings = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!; + Assert.IsNotNull(settings.ExtensionOrder); + Assert.AreEqual(0, settings.ExtensionOrder.Length); + } + + [TestMethod] + public void ExtensionOrder_PreservesOrderInJson() + { + var order = new[] { "z.ext", "a.ext", "m.ext" }; + var settings = DeserializeSettings("{}") with { ExtensionOrder = order }; + + var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.SettingsModel); + var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!; + + Assert.AreEqual("z.ext", deserialized.ExtensionOrder[0]); + Assert.AreEqual("a.ext", deserialized.ExtensionOrder[1]); + Assert.AreEqual("m.ext", deserialized.ExtensionOrder[2]); + } + + [TestMethod] + public void SortByExtensionOrder_EmptyList_ReturnsEmpty() + { + var result = ExtensionOrderHelper.SortByExtensionOrder( + new List(), + OrderAB, + s => s); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void SortByExtensionOrder_EmptyOrder_PreservesOriginalOrder() + { + var items = new List { "x", "y", "z" }; + var result = ExtensionOrderHelper.SortByExtensionOrder(items, [], s => s); + + CollectionAssert.AreEqual(ExpectedXYZ, result); + } + + [TestMethod] + public void SortByExtensionOrder_AllInOrder_SortsAccordingly() + { + var items = new List { "c", "a", "b" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderABC, s => s); + + CollectionAssert.AreEqual(ExpectedABC, result); + } + + [TestMethod] + public void SortByExtensionOrder_NoneInOrder_PreservesOriginalOrder() + { + var items = new List { "x", "y", "z" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderABC, s => s); + + CollectionAssert.AreEqual(ExpectedXYZ, result); + } + + [TestMethod] + public void SortByExtensionOrder_Mixed_OrderedFirstThenUnordered() + { + var items = new List { "x", "b", "y", "a" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s); + + CollectionAssert.AreEqual(ExpectedABXY, result); + } + + [TestMethod] + public void SortByExtensionOrder_DuplicateProviderIds_AllGroupedInOrder() + { + var items = new List { "b", "a", "b", "c", "a" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s); + + Assert.AreEqual("a", result[0]); + Assert.AreEqual("a", result[1]); + Assert.AreEqual("b", result[2]); + Assert.AreEqual("b", result[3]); + Assert.AreEqual("c", result[4]); + } + + [TestMethod] + public void SortByExtensionOrder_SingleElement_ReturnsIt() + { + var items = new List { "a" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("a", result[0]); + } + + [TestMethod] + public void SortByExtensionOrder_SameProviderItems_KeepRelativeOrder() + { + // Two providers ("a" and "b"), each contributing several commands. The sort + // must be stable so a single provider's commands are never shuffled. + var items = new List<(string Provider, int Command)> + { + ("b", 0), + ("a", 0), + ("b", 1), + ("a", 1), + ("a", 2), + }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, x => x.Provider); + + var expected = new List<(string, int)> + { + ("a", 0), + ("a", 1), + ("a", 2), + ("b", 0), + ("b", 1), + }; + CollectionAssert.AreEqual(expected, result); + } + + [TestMethod] + public void SortByExtensionOrder_ExternalCanOutrankBuiltIn_WhenBothInOrder() + { + // Built-ins load before external extensions, but the configured order lists the + // external provider first. Sorting the full list lets the external outrank the + // built-in so the result matches what the reorder dialog showed (WYSIWYG). + var items = new List { "builtin.apps", "builtin.calc", "external.foo" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderExternalThenBuiltIns, s => s); + + CollectionAssert.AreEqual(ExpectedExternalFirst, result); + } + + [TestMethod] + public void SortByExtensionOrder_NewProviderNotInOrder_GoesToEnd() + { + // A provider installed after the last reorder isn't in the saved order, so it + // keeps its natural load position at the end rather than jumping to the front. + var items = new List { "external.foo", "builtin.apps", "newly.installed" }; + + var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderExternalThenApps, s => s); + + CollectionAssert.AreEqual(ExpectedNewProviderLast, result); + } + + private static SettingsModel DeserializeSettings(string json) + { + return JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel(); + } +}