CmdPal: Pull out VM bits from ShellPage.xaml.cs (#40479)

ref #40113

Moves a lot of the "model" logic out of `ShellPage.xaml.cs` into
`ShellViewModel`.

The LARGE majority of this code is copy-paste moving code. We're now
using a couple more messages to pass navigation between the VM and the
page. And a couple new messages for passing ETW events.
This commit is contained in:
Mike Griese
2025-07-09 18:49:21 -05:00
committed by GitHub
parent f341aeb627
commit 608eb1e034
11 changed files with 453 additions and 286 deletions

View File

@@ -6,11 +6,9 @@ using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Settings;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
@@ -28,15 +26,18 @@ namespace Microsoft.CmdPal.UI.Pages;
/// </summary>
public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<PerformCommandMessage>,
IRecipient<OpenSettingsMessage>,
IRecipient<HotkeySummonMessage>,
IRecipient<ShowDetailsMessage>,
IRecipient<HideDetailsMessage>,
IRecipient<ClearSearchMessage>,
IRecipient<HandleCommandResultMessage>,
IRecipient<LaunchUriMessage>,
IRecipient<SettingsWindowClosedMessage>,
IRecipient<GoHomeMessage>,
IRecipient<GoBackMessage>,
IRecipient<ShowConfirmationMessage>,
IRecipient<ShowToastMessage>,
IRecipient<NavigateToPageMessage>,
INotifyPropertyChanged
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
@@ -50,8 +51,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly ToastWindow _toast = new();
private readonly Lock _invokeLock = new();
private Task? _handleInvokeTask;
private SettingsWindow? _settingsWindow;
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
@@ -64,8 +63,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// how we are doing navigation around
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
@@ -76,6 +73,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<LaunchUriMessage>(this);
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackMessage>(this);
WeakReferenceMessenger.Default.Register<ShowConfirmationMessage>(this);
WeakReferenceMessenger.Default.Register<ShowToastMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
RootFrame.Navigate(typeof(LoadingPage), ViewModel);
}
@@ -103,195 +106,72 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
public void Receive(PerformCommandMessage message)
public void Receive(NavigateToPageMessage message)
{
PerformCommand(message);
// TODO GH #526 This needs more better locking too
_ = _queue.TryEnqueue(() =>
{
// Also hide our details pane about here, if we had one
HideDetails();
// Navigate to the appropriate host page for that VM
RootFrame.Navigate(
message.Page switch
{
ListViewModel => typeof(ListPage),
ContentPageViewModel => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
message.Page,
message.WithAnimation ? _slideRightTransition : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
// Refocus on the Search for continual typing on the next search request
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
if (!ViewModel.IsNested)
{
// todo BODGY
RootFrame.BackStack.Clear();
}
});
}
private void PerformCommand(PerformCommandMessage message)
public void Receive(ShowConfirmationMessage message)
{
var command = message.Command.Unsafe;
if (command == null)
DispatcherQueue.TryEnqueue(async () =>
{
try
{
await HandleConfirmArgsOnUiThread(message.Args);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
});
}
public void Receive(ShowToastMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
_toast.ShowToast(message.Message);
});
}
// This gets called from the UI thread
private async Task HandleConfirmArgsOnUiThread(IConfirmationArgs? args)
{
if (args == null)
{
return;
}
if (!ViewModel.CurrentPage.IsNested)
{
// on the main page here
ViewModel.PerformTopLevelCommand(message);
}
IExtensionWrapper? extension = null;
// TODO: Actually loading up the page, or invoking the command -
// that might belong in the model, not the view?
// Especially considering the try/catch concerns around the fact that the
// COM call might just fail.
// Or the command may be a stub. Future us problem.
try
{
// In the case that we're coming from a top-level command, the
// current page's host is the global instance. We only really want
// to use that as the host of last resort.
var pageHost = ViewModel.CurrentPage?.ExtensionHost;
if (pageHost == CommandPaletteHost.Instance)
{
pageHost = null;
}
var messageHost = message.ExtensionHost;
// Use the host from the current page if it has one, else use the
// one specified in the PerformMessage for a top-level command,
// else just use the global one.
CommandPaletteHost host;
// Top level items can come through without a Extension set on the
// message. In that case, the `Context` is actually the
// TopLevelViewModel itself, and we can use that to get at the
// extension object.
extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
if (extension == null && message.Context is TopLevelViewModel topLevelViewModel)
{
extension = topLevelViewModel.ExtensionHost?.Extension;
host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance;
}
else
{
host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
}
if (extension != null)
{
if (messageHost != null)
{
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
}
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
}
ViewModel.SetActiveExtension(extension);
if (command is IPage page)
{
Logger.LogDebug($"Navigating to page");
// TODO GH #526 This needs more better locking too
_ = _queue.TryEnqueue(() =>
{
// Also hide our details pane about here, if we had one
HideDetails();
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
var isMainPage = command is MainListPage;
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.
PageViewModel pageViewModel = page switch
{
IListPage listPage => new ListViewModel(listPage, _mainTaskScheduler, host)
{
IsNested = !isMainPage,
},
IContentPage contentPage => new ContentPageViewModel(contentPage, _mainTaskScheduler, host),
_ => throw new NotSupportedException(),
};
// Kick off async loading of our ViewModel
ViewModel.LoadPageViewModel(pageViewModel);
// Navigate to the appropriate host page for that VM
RootFrame.Navigate(
page switch
{
IListPage => typeof(ListPage),
IContentPage => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
pageViewModel,
message.WithAnimation ? _slideRightTransition : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
// Refocus on the Search for continual typing on the next search request
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
if (isMainPage)
{
// todo BODGY
RootFrame.BackStack.Clear();
}
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
});
}
else if (command is IInvokableCommand invokable)
{
Logger.LogDebug($"Invoking command");
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
HandleInvokeCommand(message, invokable);
}
}
catch (Exception ex)
{
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
}
private void HandleInvokeCommand(PerformCommandMessage message, IInvokableCommand invokable)
{
// TODO GH #525 This needs more better locking.
lock (_invokeLock)
{
if (_handleInvokeTask != null)
{
// do nothing - a command is already doing a thing
}
else
{
_handleInvokeTask = Task.Run(() =>
{
try
{
var result = invokable.Invoke(message.Context);
DispatcherQueue.TryEnqueue(() =>
{
try
{
HandleCommandResultOnUiThread(result);
}
finally
{
_handleInvokeTask = null;
}
});
}
catch (Exception ex)
{
_handleInvokeTask = null;
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
});
}
}
}
// This gets called from the UI thread
private void HandleConfirmArgs(IConfirmationArgs args)
{
ConfirmResultViewModel vm = new(args, new(ViewModel.CurrentPage));
var initializeDialogTask = Task.Run(() => { InitializeConfirmationDialog(vm); });
initializeDialogTask.Wait();
await initializeDialogTask;
var resourceLoader = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance.ResourceLoader;
var confirmText = resourceLoader.GetString("ConfirmationDialog_ConfirmButtonText");
@@ -322,19 +202,16 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// };
}
DispatcherQueue.TryEnqueue(async () =>
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var performMessage = new PerformCommandMessage(vm);
PerformCommand(performMessage);
}
else
{
// cancel
}
});
var performMessage = new PerformCommandMessage(vm);
WeakReferenceMessenger.Default.Send(performMessage);
}
else
{
// cancel
}
}
private void InitializeConfirmationDialog(ConfirmResultViewModel vm)
@@ -342,79 +219,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
vm.SafeInitializePropertiesSynchronous();
}
private void HandleCommandResultOnUiThread(ICommandResult? result)
{
try
{
if (result != null)
{
var kind = result.Kind;
Logger.LogDebug($"handling {kind.ToString()}");
PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(kind));
switch (kind)
{
case CommandResultKind.Dismiss:
{
// Reset the palette to the main page and dismiss
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.GoHome:
{
// Go back to the main page, but keep it open
GoHome();
break;
}
case CommandResultKind.GoBack:
{
GoBack();
break;
}
case CommandResultKind.Hide:
{
// Keep this page open, but hide the palette.
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.KeepOpen:
{
// Do nothing.
break;
}
case CommandResultKind.Confirm:
{
if (result.Args is IConfirmationArgs a)
{
HandleConfirmArgs(a);
}
break;
}
case CommandResultKind.ShowToast:
{
if (result.Args is IToastArgs a)
{
_toast.ShowToast(a.Message);
HandleCommandResultOnUiThread(a.Result);
}
break;
}
}
}
}
catch
{
}
}
public void Receive(OpenSettingsMessage message)
{
_ = DispatcherQueue.TryEnqueue(() =>
@@ -467,14 +271,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);
public void Receive(HandleCommandResultMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
HandleCommandResultOnUiThread(message.Result.Unsafe);
});
}
private void HideDetails()
{
ViewModel.Details = null;
@@ -554,6 +350,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
public void Receive(GoBackMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoBack(message.WithAnimation, message.FocusSearch));
}
private void GoBack(bool withAnimation = true, bool focusSearch = true)
{
HideDetails();
@@ -591,14 +392,17 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
public void Receive(GoHomeMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoHome(withAnimation: message.WithAnimation, focusSearch: message.FocusSearch));
}
private void GoHome(bool withAnimation = true, bool focusSearch = true)
{
while (RootFrame.CanGoBack)
{
GoBack(withAnimation, focusSearch);
}
WeakReferenceMessenger.Default.Send<GoHomeMessage>();
}
private void BackButton_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());