From 80b0baa172eb4e2bba1567fbe8dadda193b736f4 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Sun, 21 Jun 2026 17:17:25 -0500 Subject: [PATCH] Fix CmdPal alias keystroke race condition (#41736) When typing fast after an alias trigger (e.g. '>' or 'file '), keystrokes were lost due to two race conditions: 1. The debounce timer could fire after alias-triggered navigation completed, writing stale text from the old page into the new page's ViewModel. Fixed by stopping the debounce timer in ClearSearch() and in OnCurrentPageViewModelChanged(). 2. For indirect aliases (e.g. 'file '), if debounce delivered text beyond the prefix (e.g. 'file test'), CheckAlias only did exact dictionary lookup and never matched. Added a StartsWith fallback that captures overflow text and forwards it to the destination page via SetSearchTextMessage. Changes: - AliasManager.CheckAlias: Added prefix-match fallback for indirect aliases. Sends SetSearchTextMessage with any text typed after the alias prefix. - SearchBar.ClearSearch: Stops debounce timer to prevent stale callbacks. - SearchBar.OnCurrentPageViewModelChanged: Stops debounce timer; applies pending search text from alias overflow. - Added SetSearchTextMessage for forwarding overflow keystrokes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AliasManager.cs | 51 ++++++++++++++---- .../Messages/SetSearchTextMessage.cs | 11 ++++ .../Controls/SearchBar.xaml.cs | 52 ++++++++++++++++--- 3 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SetSearchTextMessage.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs index 64e517d01d95..a9fedad93790 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -40,23 +40,54 @@ public AliasManager(TopLevelCommandManager tlcManager, ISettingsService settings public bool CheckAlias(string searchText) { + // Try exact match first (handles both direct aliases like ">" and + // indirect aliases when the user types exactly "file "). if (_settingsService.Settings.Aliases.TryGetValue(searchText, out var alias)) { - try - { - var topLevelCommand = _topLevelCommandManager.LookupCommand(alias.CommandId); - if (topLevelCommand is not null) - { - WeakReferenceMessenger.Default.Send(); + return TryFireAlias(alias, remainingText: string.Empty); + } - WeakReferenceMessenger.Default.Send(topLevelCommand.GetPerformCommandMessage()); - return true; - } + // For indirect aliases the debounce timer may deliver text beyond + // the prefix (e.g. "file test" when the user typed fast). Check if + // the search text starts with any known indirect alias prefix (#41736). + foreach (var kv in _settingsService.Settings.Aliases) + { + var candidateAlias = kv.Value; + if (!candidateAlias.IsDirect + && searchText.Length > kv.Key.Length + && searchText.StartsWith(kv.Key, StringComparison.Ordinal)) + { + var extraText = searchText[kv.Key.Length..]; + return TryFireAlias(candidateAlias, extraText); } - catch + } + + return false; + } + + private bool TryFireAlias(CommandAlias alias, string remainingText) + { + try + { + var topLevelCommand = _topLevelCommandManager.LookupCommand(alias.CommandId); + if (topLevelCommand is not null) { + WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(topLevelCommand.GetPerformCommandMessage()); + + // If there was text typed after the alias prefix, forward it so + // keystrokes aren't lost (#41736). + if (!string.IsNullOrEmpty(remainingText)) + { + WeakReferenceMessenger.Default.Send(new SetSearchTextMessage(remainingText)); + } + + return true; } } + catch + { + } return false; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SetSearchTextMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SetSearchTextMessage.cs new file mode 100644 index 000000000000..2a85d1f894cb --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SetSearchTextMessage.cs @@ -0,0 +1,11 @@ +// 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.Messages; + +/// +/// Sent after an alias triggers navigation to forward any text the user +/// typed beyond the alias prefix to the destination page (#41736). +/// +public record SetSearchTextMessage(string Text); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index c2e8c5d1ca51..fe401a84eacd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -30,6 +30,7 @@ public sealed partial class SearchBar : UserControl, IRecipient, IRecipient, IRecipient, + IRecipient, ICurrentPageAware { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -40,6 +41,9 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; + // Text to apply after the next page navigation completes (alias overflow, #41736). + private string? _pendingSearchText; + // Inline text suggestions // In 0.4-0.5 we would replace the text of the search box with the TextToSuggest // This was really cool for navigating paths in run and pretty much nowhere else. @@ -74,7 +78,6 @@ public PageViewModel? CurrentPageViewModel private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - //// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work... var @this = (SearchBar)d; if (@this is not null @@ -86,11 +89,31 @@ private static void OnCurrentPageViewModelChanged(DependencyObject d, Dependency if (@this is not null && e.NewValue is PageViewModel page) { - // TODO: In some cases we probably want commands to clear a filter - // somewhere in the process, so we need to figure out when that is. - @this.FilterBox.Text = page.SearchTextBox; + // Stop any pending debounce so a stale callback doesn't overwrite + // the new page's search text after we set it here (#41736). + @this._debounceTimer.Stop(); + + // If an alias stored overflow text, apply it instead of the page default. + var pending = @this._pendingSearchText; + @this._pendingSearchText = null; + + var textToSet = pending ?? page.SearchTextBox; + @this.FilterBox.Text = textToSet; @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); + if (pending is not null) + { + // Defer pushing overflow text into the ViewModel so the page + // can finish initializing without a premature cancellation. + @this._queue.TryEnqueue(() => + { + if (@this.CurrentPageViewModel == page) + { + page.SearchTextBox = textToSet; + } + }); + } + page.PropertyChanged += @this.Page_PropertyChanged; if (page is ListViewModel listViewModel) @@ -123,14 +146,25 @@ public SearchBar() WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } public void ClearSearch() { - // TODO GH #239 switch back when using the new MD text block - // _ = _queue.EnqueueAsync(() => + // Cancel any pending debounce to prevent stale text from being + // written after the clear (fixes alias keystroke race #41736). + _debounceTimer.Stop(); + + // Capture the current page so the queued clear only applies if we're + // still on the same page (avoids clearing a newly-navigated page). + var page = CurrentPageViewModel; _queue.TryEnqueue(new(() => { + if (CurrentPageViewModel != page) + { + return; + } + this.FilterBox.Text = string.Empty; if (CurrentPageViewModel is not null) @@ -499,6 +533,12 @@ public void Receive(GoHomeMessage message) public void Receive(FocusSearchBoxMessage message) => Focus(); + public void Receive(SetSearchTextMessage message) + { + // Store the text to apply after the next page navigation (#41736). + _pendingSearchText = message.Text; + } + private void Focus() { this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>