CmdPal: entirely redo the Run page (#39955)

This entirely rewrites the shell page. It feels a lot more like the old
run dialog now.

* It's got icons for files & exes
* it can handle network paths
* it can handle `commands /with args...`
* it'll suggest files in that path as you type
* it handles `%environmentVariables%`
* it handles `"Paths with\spaces in them"`
* it shows you the path as a suggestion, in the text box, as you move
the selection


References:
Closes #39044
Closes #39419
Closes #38298
Closes #40311


### Remaining todo's
* [x] Remove the `GenerateAppxManifest` change, and file something to
fix that. We are still generating msix's on every build, wtf
* [x] Clean-up code
* [x] Double-check loc
* [x] Remove a bunch of debug printing that we don't need anymore
* [ ] File a separate PR for moving the file (indexer) commands into a
common project, and re-use those here
* [x] Add history support again! I totally tore that out
  * did that in #40427 
* [x] make `shell:` paths and weird URI's just work. Good test is
`x-cmdpal://settings`

### further optimizations that probably aren't blocking
* [x] Our fast up-to-date is clearly broken, but I think that's been
broken since early 0.91
* [x] If the exe doesn't change, we don't need to create a new ListItem
for it. We can just re-use the current one, and just change the args
* [ ] if the directory hasn't changed, but we typed more chars (e.g.
`c:\windows\s` -> `c:\windows\sys`), we should cache the ListItem's from
the first query, and re-use them if possible.
This commit is contained in:
Mike Griese
2025-07-22 14:47:31 -05:00
committed by GitHub
parent 6ff59488eb
commit 6623d0a2ee
24 changed files with 1091 additions and 148 deletions

View File

@@ -2,7 +2,6 @@
// 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.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.ViewModels;
@@ -21,6 +20,7 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
ICurrentPageAware
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
@@ -31,6 +31,10 @@ public sealed partial class SearchBar : UserControl,
private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private bool _isBackspaceHeld;
private bool _inSuggestion;
private string? _lastText;
private string? _deletedSuggestion;
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -69,6 +73,7 @@ public sealed partial class SearchBar : UserControl,
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
}
public void ClearSearch()
@@ -125,15 +130,6 @@ public sealed partial class SearchBar : UserControl,
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
e.Handled = true;
}
else if (e.Key == VirtualKey.Right)
{
if (CurrentPageViewModel != null && !string.IsNullOrEmpty(CurrentPageViewModel.TextToSuggest))
{
FilterBox.Text = CurrentPageViewModel.TextToSuggest;
FilterBox.Select(FilterBox.Text.Length, 0);
e.Handled = true;
}
}
else if (e.Key == VirtualKey.Escape)
{
if (string.IsNullOrEmpty(FilterBox.Text))
@@ -200,12 +196,65 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
else if (e.Key == VirtualKey.Right)
{
if (_inSuggestion)
{
_inSuggestion = false;
_lastText = null;
DoFilterBoxUpdate();
}
}
else if (e.Key == VirtualKey.Down)
{
WeakReferenceMessenger.Default.Send<NavigateNextCommand>();
e.Handled = true;
}
if (_inSuggestion)
{
if (
e.Key == VirtualKey.Back ||
e.Key == VirtualKey.Delete
)
{
_deletedSuggestion = FilterBox.Text;
FilterBox.Text = _lastText ?? string.Empty;
FilterBox.Select(FilterBox.Text.Length, 0);
// Logger.LogInfo("deleting suggestion");
_inSuggestion = false;
_lastText = null;
e.Handled = true;
return;
}
var ignoreLeave =
e.Key == VirtualKey.Up ||
e.Key == VirtualKey.Down ||
e.Key == VirtualKey.RightMenu ||
e.Key == VirtualKey.LeftMenu ||
e.Key == VirtualKey.Menu ||
e.Key == VirtualKey.Shift ||
e.Key == VirtualKey.RightShift ||
e.Key == VirtualKey.LeftShift ||
e.Key == VirtualKey.RightControl ||
e.Key == VirtualKey.LeftControl ||
e.Key == VirtualKey.Control;
if (ignoreLeave)
{
return;
}
// Logger.LogInfo("leaving suggestion");
_inSuggestion = false;
_lastText = null;
}
}
private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e)
@@ -219,7 +268,7 @@ public sealed partial class SearchBar : UserControl,
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e)
{
Debug.WriteLine($"FilterBox_TextChanged: {FilterBox.Text}");
// Logger.LogInfo($"FilterBox_TextChanged: {FilterBox.Text}");
// TERRIBLE HACK TODO GH #245
// There's weird wacky bugs with debounce currently. We're trying
@@ -228,23 +277,22 @@ public sealed partial class SearchBar : UserControl,
// (otherwise aliases just stop working)
if (FilterBox.Text.Length == 1)
{
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
DoFilterBoxUpdate();
return;
}
if (_inSuggestion)
{
// Logger.LogInfo($"-- skipping, in suggestion --");
return;
}
// TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property.
_debounceTimer.Debounce(
() =>
{
// Actually plumb Filtering to the view model
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
DoFilterBoxUpdate();
},
//// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default
//// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/
@@ -254,6 +302,21 @@ public sealed partial class SearchBar : UserControl,
immediate: FilterBox.Text.Length <= 1);
}
private void DoFilterBoxUpdate()
{
if (_inSuggestion)
{
// Logger.LogInfo($"--- skipping ---");
return;
}
// Actually plumb Filtering to the view model
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
}
// Used to handle the case when a ListPage's `SearchText` may have changed
private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
@@ -273,6 +336,8 @@ public sealed partial class SearchBar : UserControl,
// ... Move the cursor to the end of the input
FilterBox.Select(FilterBox.Text.Length, 0);
}
// TODO! deal with suggestion
}
else if (property == nameof(ListViewModel.InitialSearchText))
{
@@ -290,4 +355,96 @@ public sealed partial class SearchBar : UserControl,
public void Receive(GoHomeMessage message) => ClearSearch();
public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
public void Receive(UpdateSuggestionMessage message)
{
var suggestion = message.TextToSuggest;
_queue.TryEnqueue(new(() =>
{
var clearSuggestion = string.IsNullOrEmpty(suggestion);
if (clearSuggestion && _inSuggestion)
{
// Logger.LogInfo($"Cleared suggestion \"{_lastText}\" to {suggestion}");
_inSuggestion = false;
FilterBox.Text = _lastText ?? string.Empty;
_lastText = null;
return;
}
if (clearSuggestion)
{
_deletedSuggestion = null;
return;
}
if (suggestion == _deletedSuggestion)
{
return;
}
else
{
_deletedSuggestion = null;
}
var currentText = _lastText ?? FilterBox.Text;
_lastText = currentText;
// if (_inSuggestion)
// {
// Logger.LogInfo($"Suggestion from \"{_lastText}\" to {suggestion}");
// }
// else
// {
// Logger.LogInfo($"Entering suggestion from \"{_lastText}\" to {suggestion}");
// }
_inSuggestion = true;
var matchedChars = 0;
var suggestionStartsWithQuote = suggestion.Length > 0 && suggestion[0] == '"';
var currentStartsWithQuote = currentText.Length > 0 && currentText[0] == '"';
var skipCheckingFirst = suggestionStartsWithQuote && !currentStartsWithQuote;
for (int i = skipCheckingFirst ? 1 : 0, j = 0;
i < suggestion.Length && j < currentText.Length;
i++, j++)
{
if (string.Equals(
suggestion[i].ToString(),
currentText[j].ToString(),
StringComparison.OrdinalIgnoreCase))
{
matchedChars++;
}
else
{
break;
}
}
var first = skipCheckingFirst ? "\"" : string.Empty;
var second = currentText.AsSpan(0, matchedChars);
var third = suggestion.AsSpan(matchedChars + (skipCheckingFirst ? 1 : 0));
var newText = string.Concat(
first,
second,
third);
FilterBox.Text = newText;
var wrappedInQuotes = suggestionStartsWithQuote && suggestion.Last() == '"';
if (wrappedInQuotes)
{
FilterBox.Select(
(skipCheckingFirst ? 1 : 0) + matchedChars,
Math.Max(0, suggestion.Length - matchedChars - 1 + (skipCheckingFirst ? -1 : 0)));
}
else
{
FilterBox.Select(matchedChars, suggestion.Length - matchedChars);
}
}));
}
}