mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-09 20:09:28 +02:00
## Summary
When a keyboard shortcut opens CmdPal to an extension while the palette
is already showing a dock-launched transient page, `GoHome(false)`
cannot restore the root page — the frame's back stack is empty because
the transient dock page was never pushed on top of root. The user ends
up with only the hotkey-target page in the frame with no way to navigate
back to the main list.
## Root Cause
In `ShellPage.SummonOnUiThread()`, the hotkey-to-page branch called
`GoHome(false)` before sending `ShowWindowMessage`. But when the active
page is a transient dock page, `_currentlyTransient` is still `true` and
the frame back stack is empty, so `GoHome` can't re-establish the root
page as the frame base.
## Fix
Added `ResetToHome()` to `ShellViewModel`, mirroring the pattern already
used in `WindowHiddenMessage` handling:
1. Clears `_currentlyTransient`
2. Calls `_rootPageService.GoHome()` to reset extension state
3. Sends `PerformCommandMessage` for `_rootPage` — navigating
MainListPage into the frame as the base
In `ShellPage.SummonOnUiThread()`, the `GoHome(false)` call in the
`isPage` branch is replaced with `ViewModel.ResetToHome()`. The root
page is then cleanly in the frame before the hotkey target's
`PerformCommandMessage` navigates on top of it.
Fixes #47994
Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit ba20da1611)
560 lines
20 KiB
C#
560 lines
20 KiB
C#
// 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.
|
|
|
|
using System.ComponentModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using Microsoft.CmdPal.Common;
|
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Models;
|
|
using Microsoft.CmdPal.ViewModels.Messages;
|
|
using Microsoft.CommandPalette.Extensions;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public partial class ShellViewModel : ObservableObject,
|
|
IDisposable,
|
|
IRecipient<PerformCommandMessage>,
|
|
IRecipient<HandleCommandResultMessage>,
|
|
IRecipient<WindowHiddenMessage>
|
|
{
|
|
private readonly IRootPageService _rootPageService;
|
|
private readonly IAppHostService _appHostService;
|
|
private readonly TaskScheduler _scheduler;
|
|
private readonly IPageViewModelFactoryService _pageViewModelFactory;
|
|
private readonly Lock _invokeLock = new();
|
|
private Task? _handleInvokeTask;
|
|
|
|
// Cancellation token source for page loading/navigation operations
|
|
private CancellationTokenSource? _navigationCts;
|
|
|
|
[ObservableProperty]
|
|
public partial bool IsLoaded { get; set; } = false;
|
|
|
|
[ObservableProperty]
|
|
public partial DetailsViewModel? Details { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial bool IsDetailsVisible { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial bool IsSearchBoxVisible { get; set; } = true;
|
|
|
|
private PageViewModel _currentPage;
|
|
|
|
public PageViewModel CurrentPage
|
|
{
|
|
get => _currentPage;
|
|
set
|
|
{
|
|
var oldValue = _currentPage;
|
|
if (SetProperty(ref _currentPage, value))
|
|
{
|
|
oldValue.PropertyChanged -= CurrentPage_PropertyChanged;
|
|
value.PropertyChanged += CurrentPage_PropertyChanged;
|
|
|
|
if (oldValue is IDisposable disposable)
|
|
{
|
|
try
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void CurrentPage_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(PageViewModel.HasSearchBox))
|
|
{
|
|
IsSearchBoxVisible = CurrentPage.HasSearchBox;
|
|
}
|
|
}
|
|
|
|
private IPage? _rootPage;
|
|
|
|
private bool _isNested;
|
|
private bool _currentlyTransient;
|
|
|
|
public bool IsNested => _isNested && !_currentlyTransient;
|
|
|
|
public PageViewModel NullPage { get; private set; }
|
|
|
|
public ShellViewModel(
|
|
TaskScheduler scheduler,
|
|
IRootPageService rootPageService,
|
|
IPageViewModelFactoryService pageViewModelFactory,
|
|
IAppHostService appHostService)
|
|
{
|
|
_pageViewModelFactory = pageViewModelFactory;
|
|
_scheduler = scheduler;
|
|
_rootPageService = rootPageService;
|
|
_appHostService = appHostService;
|
|
|
|
NullPage = new NullPageViewModel(_scheduler, appHostService.GetDefaultHost());
|
|
_currentPage = new LoadingPageViewModel(null, _scheduler, appHostService.GetDefaultHost());
|
|
|
|
// Register to receive messages
|
|
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<WindowHiddenMessage>(this);
|
|
}
|
|
|
|
[RelayCommand]
|
|
public async Task<bool> LoadAsync()
|
|
{
|
|
// First, do any loading that the root page service needs to do before we can
|
|
// display the root page. For example, this might include loading
|
|
// the built-in commands, or loading the settings.
|
|
await _rootPageService.PreLoadAsync();
|
|
|
|
IsLoaded = true;
|
|
|
|
// Now that the basics are set up, we can load the root page.
|
|
_rootPage = _rootPageService.GetRootPage();
|
|
|
|
// This sends a message to us to load the root page view model.
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
|
|
|
|
// Now that the root page is loaded, do any post-load work that the root page service needs to do.
|
|
// This runs asynchronously, on a background thread.
|
|
// This might include starting extensions, for example.
|
|
// Note: We don't await this, so that we can return immediately.
|
|
// This is important because we don't want to block the UI thread.
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await _rootPageService.PostLoadRootPageAsync();
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default)
|
|
{
|
|
// Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems.
|
|
// IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar
|
|
// This triggers that load generally with the InitializeCommand asynchronously when we navigate to a page.
|
|
// We could re-track the page loading status, if we need it more granularly below again, but between the initialized and error message, we can infer some status.
|
|
// We could also maybe move this thread offloading we do for loading into PageViewModel and better communicate between the two... a few different options.
|
|
|
|
////LoadedState = ViewModelLoadedState.Loading;
|
|
if (!viewModel.IsInitialized
|
|
&& viewModel.InitializeCommand is not null)
|
|
{
|
|
var outer = Task.Run(
|
|
async () =>
|
|
{
|
|
// You know, this creates the situation where we wait for
|
|
// both loading page properties, AND the items, before we
|
|
// display anything.
|
|
//
|
|
// We almost need to do an async await on initialize, then
|
|
// just a fire-and-forget on FetchItems.
|
|
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
|
|
// Definitely some more clean-up to do, but at least its centralized to one spot now.
|
|
viewModel.InitializeCommand.Execute(null);
|
|
|
|
await viewModel.InitializeCommand.ExecutionTask!;
|
|
|
|
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
|
|
{
|
|
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var t = Task.Factory.StartNew(
|
|
() =>
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
if (viewModel is IDisposable disposable)
|
|
{
|
|
try
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
CurrentPage = viewModel;
|
|
},
|
|
cancellationToken,
|
|
TaskCreationOptions.None,
|
|
_scheduler);
|
|
await t;
|
|
}
|
|
},
|
|
cancellationToken);
|
|
await outer;
|
|
}
|
|
else
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
if (viewModel is IDisposable disposable)
|
|
{
|
|
try
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
CurrentPage = viewModel;
|
|
}
|
|
}
|
|
|
|
public void Receive(PerformCommandMessage message)
|
|
{
|
|
PerformCommand(message);
|
|
}
|
|
|
|
private void PerformCommand(PerformCommandMessage message)
|
|
{
|
|
// Create/replace the navigation cancellation token.
|
|
// If one already exists, cancel and dispose it first.
|
|
var newCts = new CancellationTokenSource();
|
|
var oldCts = Interlocked.Exchange(ref _navigationCts, newCts);
|
|
if (oldCts is not null)
|
|
{
|
|
try
|
|
{
|
|
oldCts.Cancel();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
finally
|
|
{
|
|
oldCts.Dispose();
|
|
}
|
|
}
|
|
|
|
var navigationToken = newCts.Token;
|
|
|
|
var command = message.Command.Unsafe;
|
|
if (command is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Determine whether this is the root/home page navigation BEFORE
|
|
// computing providerContext. When navigating back to the root page we
|
|
// must use an empty provider context so that home-page list items don't
|
|
// inherit a pinning-capable context left over from the previous sub-page
|
|
// (which can happen e.g. when the window is hidden while on a sub-page).
|
|
// isMainPage must be evaluated here; if it were moved inside the
|
|
// "if (command is IPage)" block below, it would be too late to affect
|
|
// the providerContext that is passed to the new page view-model.
|
|
var isMainPage = command == _rootPage;
|
|
|
|
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
|
|
var providerContext = isMainPage
|
|
? CommandProviderContext.Empty
|
|
: _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
|
|
|
|
_rootPageService.OnPerformCommand(message.Context, CurrentPage.IsRootPage, host);
|
|
|
|
try
|
|
{
|
|
if (command is IPage page)
|
|
{
|
|
CoreLogger.LogDebug($"Navigating to page");
|
|
|
|
_isNested = !isMainPage;
|
|
_currentlyTransient = message.TransientPage;
|
|
|
|
// Telemetry: Track extension page navigation for session metrics
|
|
if (host is not null)
|
|
{
|
|
var extensionId = host.GetExtensionDisplayName() ?? "builtin";
|
|
var commandId = command?.Id ?? "unknown";
|
|
var commandName = command?.Name ?? "unknown";
|
|
WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
|
|
new(extensionId, commandId, commandName, true, 0));
|
|
}
|
|
|
|
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.
|
|
var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!, providerContext);
|
|
if (pageViewModel is null)
|
|
{
|
|
CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}");
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
pageViewModel.IsRootPage = isMainPage;
|
|
pageViewModel.HasBackButton = IsNested;
|
|
|
|
// Clear command bar, ViewModel initialization can already set new commands if it wants to
|
|
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
|
|
|
|
// Kick off async loading of our ViewModel
|
|
LoadPageViewModelAsync(pageViewModel, navigationToken)
|
|
.ContinueWith(
|
|
(Task t) =>
|
|
{
|
|
// clean up the navigation token if it's still ours
|
|
if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts)
|
|
{
|
|
newCts.Dispose();
|
|
}
|
|
},
|
|
navigationToken,
|
|
TaskContinuationOptions.None,
|
|
_scheduler);
|
|
|
|
// While we're loading in the background, immediately move to the next page.
|
|
NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage);
|
|
WeakReferenceMessenger.Default.Send(msg);
|
|
|
|
// 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)
|
|
{
|
|
CoreLogger.LogDebug($"Invoking command");
|
|
|
|
WeakReferenceMessenger.Default.Send<TelemetryBeginInvokeMessage>();
|
|
StartInvoke(message, invokable, host);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// TODO: It would be better to do this as a page exception, rather
|
|
// than a silent log message.
|
|
host?.Log(ex.Message);
|
|
}
|
|
}
|
|
|
|
private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
|
|
{
|
|
// TODO GH #525 This needs more better locking.
|
|
lock (_invokeLock)
|
|
{
|
|
if (_handleInvokeTask is not null)
|
|
{
|
|
// do nothing - a command is already doing a thing
|
|
}
|
|
else
|
|
{
|
|
_handleInvokeTask = Task.Run(() =>
|
|
{
|
|
SafeHandleInvokeCommandSynchronous(message, invokable, host);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
|
|
{
|
|
// Telemetry: Track command execution time and success
|
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
var command = message.Command.Unsafe;
|
|
var extensionId = host?.GetExtensionDisplayName() ?? "builtin";
|
|
var commandId = command?.Id ?? "unknown";
|
|
var commandName = command?.Name ?? "unknown";
|
|
var success = false;
|
|
|
|
try
|
|
{
|
|
// Call out to extension process.
|
|
// * May fail!
|
|
// * May never return!
|
|
var result = invokable.Invoke(message.Context);
|
|
|
|
// But if it did succeed, we need to handle the result.
|
|
UnsafeHandleCommandResult(result, message.OnBeforeShowConfirmation);
|
|
|
|
success = true;
|
|
_handleInvokeTask = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
success = false;
|
|
_handleInvokeTask = null;
|
|
|
|
// Telemetry: Track errors for session metrics
|
|
WeakReferenceMessenger.Default.Send<ErrorOccurredMessage>(new());
|
|
|
|
// TODO: It would be better to do this as a page exception, rather
|
|
// than a silent log message.
|
|
host?.Log(ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
// Telemetry: Send extension invocation metrics (always sent, even on failure)
|
|
stopwatch.Stop();
|
|
WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
|
|
new(extensionId, commandId, commandName, success, (ulong)stopwatch.ElapsedMilliseconds));
|
|
}
|
|
}
|
|
|
|
private void UnsafeHandleCommandResult(ICommandResult? result, Action? onBeforeShowConfirmation = null)
|
|
{
|
|
if (result is null)
|
|
{
|
|
// No result, nothing to do.
|
|
return;
|
|
}
|
|
|
|
var kind = result.Kind;
|
|
CoreLogger.LogDebug($"handling {kind.ToString()}");
|
|
|
|
WeakReferenceMessenger.Default.Send<TelemetryInvokeResultMessage>(new(kind));
|
|
switch (kind)
|
|
{
|
|
case CommandResultKind.Dismiss:
|
|
{
|
|
// Reset the palette to the main page and dismiss
|
|
GoHome(withAnimation: false, focusSearch: false);
|
|
WeakReferenceMessenger.Default.Send(new 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(new DismissMessage());
|
|
break;
|
|
}
|
|
|
|
case CommandResultKind.KeepOpen:
|
|
{
|
|
// Do nothing.
|
|
break;
|
|
}
|
|
|
|
case CommandResultKind.Confirm:
|
|
{
|
|
if (result.Args is IConfirmationArgs a)
|
|
{
|
|
// Give the original sender (e.g. the dock) a chance to
|
|
// prepare UI before the confirmation dialog surfaces.
|
|
try
|
|
{
|
|
onBeforeShowConfirmation?.Invoke();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError(ex.ToString());
|
|
}
|
|
|
|
WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case CommandResultKind.ShowToast:
|
|
{
|
|
if (result.Args is IToastArgs a)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
|
|
UnsafeHandleCommandResult(a.Result, onBeforeShowConfirmation);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void GoHome(bool withAnimation = true, bool focusSearch = true)
|
|
{
|
|
_rootPageService.GoHome();
|
|
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(withAnimation, focusSearch));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets navigation to the root page, clearing any transient state.
|
|
/// Use when entering from a hotkey while the palette may already be
|
|
/// showing a transient dock page.
|
|
/// </summary>
|
|
public void ResetToHome()
|
|
{
|
|
_currentlyTransient = false;
|
|
_rootPageService.GoHome();
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
|
|
}
|
|
|
|
public void GoBack(bool withAnimation = true, bool focusSearch = true)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<GoBackMessage>(new(withAnimation, focusSearch));
|
|
}
|
|
|
|
public void Receive(HandleCommandResultMessage message)
|
|
{
|
|
UnsafeHandleCommandResult(message.Result.Unsafe);
|
|
}
|
|
|
|
public void Receive(WindowHiddenMessage message)
|
|
{
|
|
// If the window was hidden while we had a transient page, we need to reset that state.
|
|
if (_currentlyTransient)
|
|
{
|
|
_currentlyTransient = false;
|
|
|
|
// navigate back to the main page without animation
|
|
GoHome(withAnimation: false, focusSearch: false);
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
|
|
}
|
|
}
|
|
|
|
private void OnUIThread(Action action)
|
|
{
|
|
_ = Task.Factory.StartNew(
|
|
action,
|
|
CancellationToken.None,
|
|
TaskCreationOptions.None,
|
|
_scheduler);
|
|
}
|
|
|
|
public void CancelNavigation()
|
|
{
|
|
_navigationCts?.Cancel();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_handleInvokeTask?.Dispose();
|
|
_navigationCts?.Dispose();
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|