Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using Microsoft.CommandPalette.Extensions;

namespace Microsoft.CmdPal.UI.ViewModels;

public static class CommandProviderContext
Expand All @@ -13,6 +15,8 @@ private sealed class EmptyCommandProviderContext : ICommandProviderContext
public string ProviderId => "<EMPTY>";

public bool SupportsPinning => false;

public ICommandItem? GetCommandItem(string id) => null;
}
}

Expand All @@ -21,4 +25,6 @@ public interface ICommandProviderContext
string ProviderId { get; }

bool SupportsPinning { get; }

ICommandItem? GetCommandItem(string id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext

private readonly ExtensionObject<ICommandProvider> _commandProvider;

private readonly ICommandProvider4? _commandProvider4;

private readonly TaskScheduler _taskScheduler;

private readonly ICommandProviderCache? _commandProviderCache;
Expand Down Expand Up @@ -59,6 +61,7 @@ public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThrea
// This ctor is only used for in-proc builtin commands. So the Unsafe!
// calls are pretty dang safe actually.
_commandProvider = new(provider);
_commandProvider4 = provider as ICommandProvider4;
_taskScheduler = mainThread;
TopLevelPageContext = new(this, _taskScheduler);

Expand All @@ -69,6 +72,7 @@ public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThrea
_commandProvider.Unsafe!.ItemsChanged += CommandProvider_ItemsChanged;

isValid = true;
SupportsPinning = _commandProvider4 is not null;
Id = provider.Id;
DisplayName = provider.DisplayName;
Icon = new(provider.Icon);
Expand Down Expand Up @@ -102,6 +106,7 @@ public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThr
}

_commandProvider = new(provider);
_commandProvider4 = provider as ICommandProvider4;

try
{
Expand All @@ -123,6 +128,7 @@ public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThr
}

isValid = true;
SupportsPinning = _commandProvider4 is not null;
}

private ProviderSettings GetProviderSettings(SettingsModel settings)
Expand Down Expand Up @@ -182,12 +188,9 @@ public async Task LoadTopLevelCommands(IServiceProvider serviceProvider)
}

ICommandItem[] pinnedCommands = [];
ICommandProvider4? four = null;
if (model is ICommandProvider4 definitelyFour)
var four = _commandProvider4;
if (four is not null)
{
four = definitelyFour; // stash this away so we don't need to QI again
SupportsPinning = true;

// Load pinned commands from saved settings
pinnedCommands = LoadPinnedCommands(four, providerSettings);
}
Expand Down Expand Up @@ -483,6 +486,25 @@ public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)

public ICommandProviderContext GetProviderContext() => this;

public ICommandItem? GetCommandItem(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}

try
{
return _commandProvider4?.GetCommandItem(id);
}
catch (Exception e)
{
Logger.LogError($"Failed to load command item {id} from provider {ProviderId}: {e.Message}");
}

return null;
}

public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;

public override int GetHashCode() => _commandProvider.GetHashCode();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// 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 Microsoft.CommandPalette.Extensions;

namespace Microsoft.CmdPal.UI.ViewModels.Messages;

public record GoToPageMessage(PerformCommandMessage CommandMessage, NavigationMode NavigationMode);
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public record PerformCommandMessage

public bool TransientPage { get; set; }

public AppExtensionHost? HostOverride { get; set; }

public ICommandProviderContext? ProviderContextOverride { get; set; }

public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;
Expand Down
88 changes: 78 additions & 10 deletions src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,10 @@ private void PerformCommand(PerformCommandMessage message)
// the providerContext that is passed to the new page view-model.
var isMainPage = command == _rootPage;

var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var host = message.HostOverride ?? _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var providerContext = isMainPage
? CommandProviderContext.Empty
: _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
: message.ProviderContextOverride ?? _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);

_rootPageService.OnPerformCommand(message.Context, CurrentPage.IsRootPage, host);

Expand Down Expand Up @@ -338,7 +338,7 @@ private void PerformCommand(PerformCommandMessage message)
CoreLogger.LogDebug($"Invoking command");

WeakReferenceMessenger.Default.Send<TelemetryBeginInvokeMessage>();
StartInvoke(message, invokable, host);
StartInvoke(message, invokable, host, providerContext);
}
}
catch (Exception ex)
Expand All @@ -349,7 +349,7 @@ private void PerformCommand(PerformCommandMessage message)
}
}

private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host, ICommandProviderContext providerContext)
{
// TODO GH #525 This needs more better locking.
lock (_invokeLock)
Expand All @@ -362,13 +362,13 @@ private void StartInvoke(PerformCommandMessage message, IInvokableCommand invoka
{
_handleInvokeTask = Task.Run(() =>
{
SafeHandleInvokeCommandSynchronous(message, invokable, host);
SafeHandleInvokeCommandSynchronous(message, invokable, host, providerContext);
});
}
}
}

private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host, ICommandProviderContext providerContext)
{
// Telemetry: Track command execution time and success
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
Expand All @@ -386,7 +386,7 @@ private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, I
var result = invokable.Invoke(message.Context);

// But if it did succeed, we need to handle the result.
UnsafeHandleCommandResult(result);
UnsafeHandleCommandResult(result, host, providerContext, message.TransientPage);

success = true;
_handleInvokeTask = null;
Expand All @@ -412,7 +412,7 @@ private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, I
}
}

private void UnsafeHandleCommandResult(ICommandResult? result)
private void UnsafeHandleCommandResult(ICommandResult? result, AppExtensionHost? host = null, ICommandProviderContext? providerContext = null, bool transientPage = false)
{
if (result is null)
{
Expand Down Expand Up @@ -447,6 +447,20 @@ private void UnsafeHandleCommandResult(ICommandResult? result)
break;
}

case CommandResultKind.GoToPage:
{
if (result.Args is IGoToPageArgs a)
{
HandleGoToPage(a, host, providerContext, transientPage);
}
else
{
CoreLogger.LogError("Invalid arguments for CommandResultKind.GoToPage");
}

break;
}

case CommandResultKind.Hide:
{
// Keep this page open, but hide the palette.
Expand Down Expand Up @@ -475,14 +489,67 @@ private void UnsafeHandleCommandResult(ICommandResult? result)
if (result.Args is IToastArgs a)
{
WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
UnsafeHandleCommandResult(a.Result);
UnsafeHandleCommandResult(a.Result, host, providerContext, transientPage);
}

break;
}
}
}

private void HandleGoToPage(IGoToPageArgs args, AppExtensionHost? host, ICommandProviderContext? providerContext, bool transientPage)
{
if (providerContext is null)
{
CoreLogger.LogWarning($"Unable to navigate to page '{args.PageId}' because no command provider context was available.");
host?.Log($"Unable to navigate to page '{args.PageId}' because no command provider context was available.");
return;
}

if (string.IsNullOrWhiteSpace(args.PageId))
{
CoreLogger.LogWarning("Unable to navigate to a page because the requested page id was empty.");
host?.Log("Unable to navigate to a page because the requested page id was empty.");
return;
}

ICommandItem? targetItem;
try
{
// Resolve the target command item before we hop onto the UI thread.
// ICommandProvider4.GetCommandItem may need to materialize a dynamic
// command by id, and we do not want that provider work to block frame
// navigation or input processing.
targetItem = providerContext.GetCommandItem(args.PageId);
}
catch (Exception ex)
{
CoreLogger.LogError($"Failed to retrieve page '{args.PageId}' from provider '{providerContext.ProviderId}'.", ex);
host?.Log($"Failed to retrieve page '{args.PageId}' from provider '{providerContext.ProviderId}': {ex.Message}");
return;
}

if (targetItem?.Command is not { } targetCommand)
{
CoreLogger.LogWarning($"Provider '{providerContext.ProviderId}' could not supply a command for page '{args.PageId}'.");
host?.Log($"Provider '{providerContext.ProviderId}' could not supply a command for page '{args.PageId}'.");
return;
}

var performMessage = new PerformCommandMessage(new ExtensionObject<ICommand>(targetCommand), new ExtensionObject<ICommandItem>(targetItem))
{
// Preserve the original extension identity across the deferred
// GoBack/GoHome + follow-up command sequence. By the time the
// follow-up PerformCommandMessage runs, CurrentPage may already be
// pointing at the page we navigated back to.
HostOverride = host,
ProviderContextOverride = providerContext,
TransientPage = transientPage,
};

WeakReferenceMessenger.Default.Send(new GoToPageMessage(performMessage, args.NavigationMode));
}

public void GoHome(bool withAnimation = true, bool focusSearch = true)
{
_rootPageService.GoHome();
Expand All @@ -496,7 +563,7 @@ public void GoBack(bool withAnimation = true, bool focusSearch = true)

public void Receive(HandleCommandResultMessage message)
{
UnsafeHandleCommandResult(message.Result.Unsafe);
UnsafeHandleCommandResult(message.Result.Unsafe, CurrentPage.ExtensionHost, CurrentPage.ProviderContext, _currentlyTransient);
}

public void Receive(WindowHiddenMessage message)
Expand Down Expand Up @@ -528,6 +595,7 @@ public void CancelNavigation()

public void Dispose()
{
WeakReferenceMessenger.Default.UnregisterAll(this);
_handleInvokeTask?.Dispose();
_navigationCts?.Dispose();

Expand Down
63 changes: 63 additions & 0 deletions src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<SettingsWindowClosedMessage>,
IRecipient<GoHomeMessage>,
IRecipient<GoBackMessage>,
IRecipient<GoToPageMessage>,
IRecipient<ShowConfirmationMessage>,
IRecipient<ShowToastMessage>,
IRecipient<NavigateToPageMessage>,
Expand Down Expand Up @@ -98,6 +99,7 @@ public ShellPage()

WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackMessage>(this);
WeakReferenceMessenger.Default.Register<GoToPageMessage>(this);
WeakReferenceMessenger.Default.Register<ShowConfirmationMessage>(this);
WeakReferenceMessenger.Default.Register<ShowToastMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
Expand Down Expand Up @@ -212,6 +214,26 @@ public void Receive(ShowToastMessage message)
});
}

public void Receive(GoToPageMessage message)
{
_ = DispatcherQueue.TryEnqueue(() =>
{
if (_isDisposed)
{
return;
}

try
{
HandleGoToPageOnUiThread(message);
}
catch (Exception ex)
{
Logger.LogError("Failed to handle GoToPage message", ex);
}
});
}

public void Receive(ShowPinToDockDialogMessage message)
{
DispatcherQueue.TryEnqueue(async () =>
Expand Down Expand Up @@ -472,6 +494,47 @@ private void SummonOnUiThread(HotkeySummonMessage message)

public void Receive(GoBackMessage message) => _ = DispatcherQueue.TryEnqueue(() => GoBack(message.WithAnimation, message.FocusSearch));

private void HandleGoToPageOnUiThread(GoToPageMessage message)
{
switch (message.NavigationMode)
{
case NavigationMode.GoHome:
// GoToPage is composite: go home first, then run the target command.
// Queue the second step so CurrentPage can update after navigation.
GoHome(message.CommandMessage.WithAnimation, focusSearch: false);
_ = _queue.TryEnqueue(() => SafeSendGoToPageCommand(message.CommandMessage));
return;

case NavigationMode.GoBack:
// Same idea as GoHome: go back first, then run the target command;
// TODO: This is consistent with the other modes, but I wonder if we should instead try to be smarter
// and see if the target page is already in the back stack, and if so just go back to it.
// That might be a nicer experience, but it also might be more complex to implement, at least with
// parameterized pages.
GoBack(message.CommandMessage.WithAnimation, focusSearch: false);
_ = _queue.TryEnqueue(() => SafeSendGoToPageCommand(message.CommandMessage));
return;

case NavigationMode.Push:
default:
// Push has no rewind step, so we can run the target command now.
SafeSendGoToPageCommand(message.CommandMessage);
return;
}

void SafeSendGoToPageCommand(PerformCommandMessage commandMessage)
{
try
{
WeakReferenceMessenger.Default.Send(commandMessage);
}
catch (Exception ex)
{
Logger.LogError("Failed to dispatch deferred GoToPage command", ex);
}
}
}

private void GoBack(bool withAnimation = true, bool focusSearch = true)
{
HideDetails();
Expand Down
Loading
Loading