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, () =>