From 169bfe3f04ad2e202879e85837b4b132fbd8ae43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 26 Feb 2026 13:17:34 +0100 Subject: [PATCH] CmdPal: Lightning-fast mode (#45764) ## Summary of the Pull Request This PR unlocks lightning-fast mode for Command Palette: - Hides visual and motion distractions when updating the result list: - Ensures the first interactable result item is selected as early as possible after the result list is updated, reducing flashing and blinking caused by the selection highlight moving around. - Removes the list item selection indicator animation (unfortunately by removing the pill altogether for now) and prevents it from temporarily appearing on other items as the selection moves. - Adds a new "Results" section header above the home page results when no other section is present. - This ensures the first item on the home page has consistent visuals and styling, preventing offsets and excessive visual changes when elements are replaced in place. - Improves update performance and container reuse: - Fixes the `removed` output parameter in `ListHelper.UpdateInPlace` to only include items that were actually removed (items that were merely moved to a different position should not be reported as removed). - Adds unit tests to prevent regression. - Updates `ListHelper.UpdateInPlace` for `ObservableCollection` to use `Move` instead of `Remove`/`Add`, and avoids `Clear` to prevent `ListView` resets (which force recreation of all item containers and are expensive). - Adds a simple cache for list page item view models to reduce unnecessary recreation during forward incremental search. - `ListViewModel` and `FetchItems` have no notion of item lifetime or incremental search phase, so the cache intentionally remains simple rather than clever. - Updates ListPage templates to make them a little lighter: - Tag template uses OneTime, instead of OneWay - since Tag is immutable - Replaces ItemsControl with ItemsRepeater for Tag list on list items - Increases the debounce for showing the details pane and adds a debounce for hiding it. This improves performance when browsing the list and prevents the details pane animation from bouncing left and right ## Pictures? Moving! https://github.com/user-attachments/assets/36428d20-cf46-4321-83c0-d94d6d4a2299 ## PR Checklist - [x] Closes: #44407 - [x] Closes: #45691 --- .../Commands/MainListPage.cs | 44 +- .../Commands/MainListPageResultFactory.cs | 19 +- .../ItemsUpdatedEventArgs.cs | 15 + .../ListViewModel.cs | 206 +++-- .../Properties/Resources.Designer.cs | 11 +- .../Properties/Resources.resx | 4 + .../ExtViews/ListPage.xaml | 99 ++- .../ExtViews/ListPage.xaml.cs | 392 ++++++--- .../Pages/ShellPage.xaml.cs | 85 +- .../MainListPageResultFactoryTests.cs | 44 +- .../ListHelpersInPlaceUpdateTests.cs | 746 ++++++++++++++++++ .../ListHelpers.cs | 375 +++++++-- 12 files changed, 1714 insertions(+), 326 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ItemsUpdatedEventArgs.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/ListHelpersInPlaceUpdateTests.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 90a153c924..5001dc2386 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -36,6 +36,11 @@ public sealed partial class MainListPage : DynamicListPage, private readonly ScoringFunction _fallbackScoringFunction; private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider; + // Stable separator instances so that the VM cache and InPlaceUpdateList + // recognise them across successive GetItems() calls + private readonly Separator _resultsSeparator = new(Resources.results); + private readonly Separator _fallbacksSeparator = new(Resources.fallbacks); + private RoScored[]? _filteredItems; private RoScored[]? _filteredApps; @@ -171,9 +176,40 @@ public sealed partial class MainListPage : DynamicListPage, // filtered results. if (string.IsNullOrWhiteSpace(SearchText)) { - return _tlcManager.TopLevelCommands - .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)) - .ToArray(); + var allCommands = _tlcManager.TopLevelCommands; + + // First pass: count eligible commands + var eligibleCount = 0; + for (var i = 0; i < allCommands.Count; i++) + { + var cmd = allCommands[i]; + if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title)) + { + eligibleCount++; + } + } + + if (eligibleCount == 0) + { + return []; + } + + // +1 for the separator + var result = new IListItem[eligibleCount + 1]; + result[0] = _resultsSeparator; + + // Second pass: populate + var writeIndex = 1; + for (var i = 0; i < allCommands.Count; i++) + { + var cmd = allCommands[i]; + if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title)) + { + result[writeIndex++] = cmd; + } + } + + return result; } else { @@ -190,6 +226,8 @@ public sealed partial class MainListPage : DynamicListPage, validScoredFallbacks, _filteredApps, validFallbacks, + _resultsSeparator, + _fallbacksSeparator, AppResultLimit); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs index 602f978ce2..da02165955 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs @@ -21,6 +21,8 @@ internal static class MainListPageResultFactory IList>? scoredFallbackItems, IList>? filteredApps, IList>? fallbackItems, + IListItem resultsSeparator, + IListItem fallbacksSeparator, int appResultLimit) { if (appResultLimit < 0) @@ -40,8 +42,13 @@ internal static class MainListPageResultFactory int nonEmptyFallbackCount = fallbackItems?.Count ?? 0; // Allocate the exact size of the result array. - // We'll add an extra slot for the fallbacks section header if needed. - int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0); + // We'll add an extra slot for the fallbacks section header if needed, + // and another for the "Results" section header when merged results exist. + int mergedCount = len1 + len2 + len3; + bool needsResultsHeader = mergedCount > 0; + int totalCount = mergedCount + nonEmptyFallbackCount + + (needsResultsHeader ? 1 : 0) + + (nonEmptyFallbackCount > 0 ? 1 : 0); var result = new IListItem[totalCount]; @@ -49,6 +56,12 @@ internal static class MainListPageResultFactory int idx1 = 0, idx2 = 0, idx3 = 0; int writePos = 0; + // Add "Results" section header when merged results will precede the fallbacks. + if (needsResultsHeader) + { + result[writePos++] = resultsSeparator; + } + // Merge while all three lists have items. To maintain a stable sort, the // priority is: list1 > list2 > list3 when scores are equal. while (idx1 < len1 && idx2 < len2 && idx3 < len3) @@ -132,7 +145,7 @@ internal static class MainListPageResultFactory // Create the fallbacks section header if (fallbackItems.Count > 0) { - result[writePos++] = new Separator(Properties.Resources.fallbacks); + result[writePos++] = fallbacksSeparator; } for (int i = 0; i < fallbackItems.Count; i++) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ItemsUpdatedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ItemsUpdatedEventArgs.cs new file mode 100644 index 0000000000..7481c1699e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ItemsUpdatedEventArgs.cs @@ -0,0 +1,15 @@ +// 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.Core.ViewModels; + +public sealed partial class ItemsUpdatedEventArgs : EventArgs +{ + public bool ForceFirstItem { get; } + + public ItemsUpdatedEventArgs(bool forceFirstItem) + { + ForceFirstItem = forceFirstItem; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index 6f5d133e7a..9ee59ae7de 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -3,9 +3,12 @@ // See the LICENSE file in the project root for more information. using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Common; using Microsoft.CmdPal.Common.Helpers; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; @@ -16,9 +19,10 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class ListViewModel : PageViewModel, IDisposable { - // private readonly HashSet _itemCache = []; private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler); + private readonly Dictionary _vmCache = new(new ProxyReferenceEqualityComparer()); + // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items? // Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change @@ -37,7 +41,7 @@ public partial class ListViewModel : PageViewModel, IDisposable private InterlockedBoolean _isLoading; private bool _isFetching; - public event TypedEventHandler? ItemsUpdated; + public event TypedEventHandler? ItemsUpdated; public bool ShowEmptyContent => IsInitialized && @@ -80,6 +84,9 @@ public partial class ListViewModel : PageViewModel, IDisposable private ListItemViewModel? _lastSelectedItem; + // For cancelling a deferred SafeSlowInit when the user navigates rapidly + private CancellationTokenSource? _selectedItemCts; + public override bool IsInitialized { get => base.IsInitialized; protected set @@ -113,10 +120,6 @@ public partial class ListViewModel : PageViewModel, IDisposable protected override void OnSearchTextBoxUpdated(string searchTextBox) { - //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view - //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... - //// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList? - // Dynamic pages will handler their own filtering. They will tell us if // something needs to change, by raising ItemsChanged. if (_isDynamic) @@ -132,24 +135,24 @@ public partial class ListViewModel : PageViewModel, IDisposable // concurrently. _ = filterTaskFactory.StartNew( () => - { - filterCancellationTokenSource.Token.ThrowIfCancellationRequested(); + { + filterCancellationTokenSource.Token.ThrowIfCancellationRequested(); - try - { - if (_model.Unsafe is IDynamicListPage dynamic) + try { - dynamic.SearchText = searchTextBox; + if (_model.Unsafe is IDynamicListPage dynamic) + { + dynamic.SearchText = searchTextBox; + } } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - ShowException(ex, _model?.Unsafe?.Name); - } - }, + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + } + }, filterCancellationTokenSource.Token, TaskCreationOptions.None, filterTaskFactory.Scheduler!); @@ -162,7 +165,7 @@ public partial class ListViewModel : PageViewModel, IDisposable ApplyFilterUnderLock(); } - ItemsUpdated?.Invoke(this, EventArgs.Empty); + ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true)); UpdateEmptyContent(); _isLoading.Clear(); } @@ -198,12 +201,10 @@ public partial class ListViewModel : PageViewModel, IDisposable var cancellationToken = _fetchItemsCancellationTokenSource.Token; - // TEMPORARY: just plop all the items into a single group - // see 9806fe5d8 for the last commit that had this with sections _isFetching = true; // Collect all the items into new viewmodels - Collection newViewModels = []; + List newViewModels = []; try { @@ -221,11 +222,10 @@ public partial class ListViewModel : PageViewModel, IDisposable return; } - // TODO we can probably further optimize this by also keeping a - // HashSet of every ExtensionObject we currently have, and only - // building new viewmodels for the ones we haven't already built. var showsTitle = GridProperties?.ShowTitle ?? true; var showsSubtitle = GridProperties?.ShowSubtitle ?? true; + var created = 0; + var reused = 0; foreach (var item in newItems) { // Check for cancellation during item processing @@ -234,17 +234,33 @@ public partial class ListViewModel : PageViewModel, IDisposable return; } - ListItemViewModel viewModel = new(item, new(this)); + if (_vmCache.TryGetValue(item, out var existing)) + { + existing.LayoutShowsTitle = showsTitle; + existing.LayoutShowsSubtitle = showsSubtitle; + newViewModels.Add(existing); + reused++; + continue; + } + + var viewModel = new ListItemViewModel(item, new(this)); // If an item fails to load, silently ignore it. if (viewModel.SafeFastInit()) { viewModel.LayoutShowsTitle = showsTitle; viewModel.LayoutShowsSubtitle = showsSubtitle; + + _vmCache[item] = viewModel; newViewModels.Add(viewModel); + created++; } } +#if DEBUG + CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached"); +#endif + // Check for cancellation before initializing first twenty items if (cancellationToken.IsCancellationRequested) { @@ -271,13 +287,22 @@ public partial class ListViewModel : PageViewModel, IDisposable return; } - List removedItems = []; + List removedItems; lock (_listLock) { // Now that we have new ViewModels for everything from the // extension, smartly update our list of VMs ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems); + _vmCache.Clear(); + foreach (var vm in newViewModels) + { + if (vm.Model.Unsafe is { } li) + { + _vmCache[li] = vm; + } + } + // DO NOT ThrowIfCancellationRequested AFTER THIS! If you do, // you'll clean up list items that we've now transferred into // .Items @@ -288,9 +313,6 @@ public partial class ListViewModel : PageViewModel, IDisposable { removedItem.SafeCleanup(); } - - // TODO: Iterate over everything in Items, and prune items from the - // cache if we don't need them anymore } catch (OperationCanceledException) { @@ -342,13 +364,14 @@ public partial class ListViewModel : PageViewModel, IDisposable { // A dynamic list? Even better! Just stick everything into // FilteredItems. The extension already did any filtering it cared about. - ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState)); + var snapshot = Items.Where(i => !i.IsInErrorState).ToList(); + ListHelpers.InPlaceUpdateList(FilteredItems, snapshot); } UpdateEmptyContent(); } - ItemsUpdated?.Invoke(this, EventArgs.Empty); + ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested)); _isLoading.Clear(); }); } @@ -485,40 +508,58 @@ public partial class ListViewModel : PageViewModel, IDisposable private void SetSelectedItem(ListItemViewModel item) { - if (!item.SafeSlowInit()) - { - // Even if initialization fails, we need to hide any previously shown details - DoOnUiThread(() => - { - WeakReferenceMessenger.Default.Send(); - }); - return; - } - - // GH #322: - // For inexplicable reasons, if you try updating the command bar and - // the details on the same UI thread tick as updating the list, we'll - // explode - DoOnUiThread( - () => - { - WeakReferenceMessenger.Default.Send(new(item)); - - if (ShowDetails && item.HasDetails) - { - WeakReferenceMessenger.Default.Send(new(item.Details)); - } - else - { - WeakReferenceMessenger.Default.Send(); - } - - TextToSuggest = item.TextToSuggest; - WeakReferenceMessenger.Default.Send(new(item.TextToSuggest)); - }); - _lastSelectedItem = item; _lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged; + + WeakReferenceMessenger.Default.Send(new(item)); + + // Cancel any in-flight slow init from a previous selection and defer + // the expensive work (extension IPC for MoreCommands, details) so + // rapid arrow-key navigation skips intermediate items entirely. + _selectedItemCts?.Cancel(); + var cts = _selectedItemCts = new CancellationTokenSource(); + var ct = cts.Token; + + _ = Task.Run( + () => + { + if (ct.IsCancellationRequested) + { + return; + } + + if (!item.SafeSlowInit()) + { + if (ct.IsCancellationRequested) + { + return; + } + + WeakReferenceMessenger.Default.Send(); + + return; + } + + if (ct.IsCancellationRequested) + { + return; + } + + // SafeSlowInit completed on a background thread — details + // messages will be marshalled to the UI thread by the receiver. + if (ShowDetails && item.HasDetails) + { + WeakReferenceMessenger.Default.Send(new(item.Details)); + } + else + { + WeakReferenceMessenger.Default.Send(); + } + + TextToSuggest = item.TextToSuggest; + WeakReferenceMessenger.Default.Send(new(item.TextToSuggest)); + }, + ct); } private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -557,21 +598,12 @@ public partial class ListViewModel : PageViewModel, IDisposable private void ClearSelectedItem() { - // GH #322: - // For inexplicable reasons, if you try updating the command bar and - // the details on the same UI thread tick as updating the list, we'll - // explode - DoOnUiThread( - () => - { - WeakReferenceMessenger.Default.Send(new(null)); + _selectedItemCts?.Cancel(); - WeakReferenceMessenger.Default.Send(); - - WeakReferenceMessenger.Default.Send(new(string.Empty)); - - TextToSuggest = string.Empty; - }); + WeakReferenceMessenger.Default.Send(new(null)); + WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new(string.Empty)); + TextToSuggest = string.Empty; } public override void InitializeProperties() @@ -763,6 +795,10 @@ public partial class ListViewModel : PageViewModel, IDisposable _fetchItemsCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Dispose(); _fetchItemsCancellationTokenSource = null; + + _selectedItemCts?.Cancel(); + _selectedItemCts?.Dispose(); + _selectedItemCts = null; } protected override void UnsafeCleanup() @@ -775,6 +811,7 @@ public partial class ListViewModel : PageViewModel, IDisposable _cancellationTokenSource?.Cancel(); filterCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Cancel(); + _selectedItemCts?.Cancel(); lock (_listLock) { @@ -801,4 +838,11 @@ public partial class ListViewModel : PageViewModel, IDisposable model.ItemsChanged -= Model_ItemsChanged; } } + + private sealed class ProxyReferenceEqualityComparer : IEqualityComparer + { + public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y); + + public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index 6974a1fefd..c95c17acd7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -474,6 +474,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } + /// + /// Looks up a localized string similar to Results. + /// + public static string results { + get { + return ResourceManager.GetString("results", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show details. /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index 802205c5c2..f4d58d2e6f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -264,4 +264,8 @@ Show details Name for the command that shows details of an item + + Results + Section title for list of all search results that doesn't fall into any other category + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 1cea1b8b8c..09c13a20aa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -201,12 +201,70 @@ + + + +