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 @@ -4,11 +4,15 @@

using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Auth;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;

namespace Microsoft.CmdPal.UI.ViewModels;

Expand All @@ -28,6 +32,8 @@ public abstract partial class AppExtensionHost : IExtensionHost, IExtensionHost2

public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd;

public static void SetUiDispatcherQueue(DispatcherQueue queue) => UiDispatcher.Queue = queue;

public void DebugLog(string message)
{
#if DEBUG
Expand Down Expand Up @@ -178,7 +184,88 @@ public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
public IAsyncOperation<IAuthorizationResult> RequestAuthorizationAsync(IAuthorizationRequest request)
=> AuthBrokerService.Instance.RequestAuthorizationAsync(request, this);

/// <summary>
/// <see cref="IExtensionHost2"/>. Navigate Command Palette to a page owned by
/// the extension. Reuses the existing navigation pipeline: a
/// <see cref="PerformCommandMessage"/> with <see cref="PerformCommandMessage.ShowWindowIfPage"/>
/// set foregrounds the window and navigates when the command is a page. When
/// the mode is GoHome or GoBack, the stack is reset first via the matching
/// message. A null page is treated as a graceful no-op.
/// </summary>
public IAsyncAction GoToPageAsync(ICommand? page, NavigationMode navigationMode)
{
if (page is null)
{
return Task.CompletedTask.AsAsyncAction();
}

void SendNavigation()
{
switch (navigationMode)
{
case NavigationMode.GoHome:
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false, FocusSearch: false));
break;
case NavigationMode.GoBack:
WeakReferenceMessenger.Default.Send<GoBackMessage>(new(WithAnimation: false, FocusSearch: false));
break;
case NavigationMode.Push:
default:
break;
}

WeakReferenceMessenger.Default.Send<PerformCommandMessage>(
new(new ExtensionObject<ICommand>(page)) { ShowWindowIfPage = true });
}

// PerformCommand (the receiver of PerformCommandMessage) is UI-thread affine:
// it creates page view models and drives frame navigation. Extensions call
// this method from their own (non-UI) thread, so marshal the sends onto the
// UI thread. When there is no registered dispatcher (unit tests) or we are
// already on the UI thread, run inline so behavior and the returned action
// complete synchronously.
var dispatcher = UiDispatcher.Queue;
if (dispatcher is null || dispatcher.HasThreadAccess)
{
SendNavigation();
return Task.CompletedTask.AsAsyncAction();
}

var tcs = new TaskCompletionSource();
if (!dispatcher.TryEnqueue(() =>
{
try
{
SendNavigation();
tcs.TrySetResult();
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}))
{
// Enqueue failed (e.g. dispatcher shutting down); fall back to inline so
// the caller's await is never left hanging.
SendNavigation();
tcs.TrySetResult();
}

return tcs.Task.AsAsyncAction();
}

public abstract string? GetExtensionDisplayName();

// Holds the UI DispatcherQueue in a dedicated nested type so that reading it
// from GoToPageAsync does not force AppExtensionHost's static initializer to
// run. That initializer builds a GlobalLogPageContext, which captures the
// current SynchronizationContext, so forcing it on a non-UI thread (as in the
// headless unit tests) would throw. Keeping the queue here lets the inline
// navigation path run without touching that initializer.
private static class UiDispatcher
{
public static DispatcherQueue? Queue { get; set; }
}
}

public interface IAppHostService
Expand Down
4 changes: 4 additions & 0 deletions src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ public MainWindow()
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
}

// Give host-driven navigation (GoToPageAsync) the UI dispatcher so it can
// marshal onto the UI thread when an extension calls it from a background thread.
CommandPaletteHost.SetUiDispatcherQueue(this.DispatcherQueue);

// Give the authorization broker a window-aware platform so it can open
// the browser and re-foreground Command Palette when a redirect lands.
ViewModels.Auth.AuthBrokerService.Instance.SetPlatform(new WindowAuthBrokerPlatform());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Microsoft.CmdPal.UI.ViewModels.UnitTests.Auth;

[TestClass]
public sealed partial class AppExtensionHostNavigationTests
{
private sealed partial class TestAppExtensionHost : AppExtensionHost
{
public override string? GetExtensionDisplayName() => "Test Host";
}

// A minimal page so the recipient can confirm the exact command was wrapped.
private sealed partial class TestPage : Command
{
}

[TestMethod]
public async Task GoToPageAsync_Push_SendsPerformCommandWithShowWindow()
{
var recipient = new object();
PerformCommandMessage? received = null;
var homeOrBackSent = false;

WeakReferenceMessenger.Default.Register<PerformCommandMessage>(recipient, (_, m) => received = m);
WeakReferenceMessenger.Default.Register<GoHomeMessage>(recipient, (_, _) => homeOrBackSent = true);
WeakReferenceMessenger.Default.Register<GoBackMessage>(recipient, (_, _) => homeOrBackSent = true);

try
{
var host = new TestAppExtensionHost();
var page = new TestPage();

await host.GoToPageAsync(page, NavigationMode.Push);

Assert.IsNotNull(received, "PerformCommandMessage was not sent");
Assert.IsTrue(received!.ShowWindowIfPage, "ShowWindowIfPage should be set");
Assert.AreSame(page, received.Command.Unsafe, "the wrapped command should be the supplied page");
Assert.IsFalse(homeOrBackSent, "Push must not reset the navigation stack");
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}

[TestMethod]
public async Task GoToPageAsync_GoHome_SendsGoHomeThenPerformCommand()
{
var recipient = new object();
var goHomeSent = false;
var goBackSent = false;
var performSent = false;

WeakReferenceMessenger.Default.Register<GoHomeMessage>(recipient, (_, _) => goHomeSent = true);
WeakReferenceMessenger.Default.Register<GoBackMessage>(recipient, (_, _) => goBackSent = true);
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(recipient, (_, _) => performSent = true);

try
{
var host = new TestAppExtensionHost();

await host.GoToPageAsync(new TestPage(), NavigationMode.GoHome);

Assert.IsTrue(goHomeSent, "GoHomeMessage should be sent for GoHome mode");
Assert.IsFalse(goBackSent, "GoBackMessage should not be sent for GoHome mode");
Assert.IsTrue(performSent, "PerformCommandMessage should still be sent");
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}

[TestMethod]
public async Task GoToPageAsync_GoBack_SendsGoBackThenPerformCommand()
{
var recipient = new object();
var goHomeSent = false;
var goBackSent = false;
var performSent = false;

WeakReferenceMessenger.Default.Register<GoHomeMessage>(recipient, (_, _) => goHomeSent = true);
WeakReferenceMessenger.Default.Register<GoBackMessage>(recipient, (_, _) => goBackSent = true);
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(recipient, (_, _) => performSent = true);

try
{
var host = new TestAppExtensionHost();

await host.GoToPageAsync(new TestPage(), NavigationMode.GoBack);

Assert.IsTrue(goBackSent, "GoBackMessage should be sent for GoBack mode");
Assert.IsFalse(goHomeSent, "GoHomeMessage should not be sent for GoBack mode");
Assert.IsTrue(performSent, "PerformCommandMessage should still be sent");
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}

[TestMethod]
public async Task GoToPageAsync_NullPage_IsGracefulNoOp()
{
var recipient = new object();
var anySent = false;

WeakReferenceMessenger.Default.Register<PerformCommandMessage>(recipient, (_, _) => anySent = true);
WeakReferenceMessenger.Default.Register<GoHomeMessage>(recipient, (_, _) => anySent = true);
WeakReferenceMessenger.Default.Register<GoBackMessage>(recipient, (_, _) => anySent = true);

try
{
var host = new TestAppExtensionHost();

await host.GoToPageAsync(null, NavigationMode.Push);

Assert.IsFalse(anySent, "a null page should not send any navigation message");
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;

namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Auth;

[TestClass]
public class ExtensionHostNavigationTests
{
// A host that implements only IExtensionHost (no IExtensionHost2), i.e. an
// older Command Palette that predates host-driven navigation.
private sealed class LegacyHost : IExtensionHost
{
public IAsyncAction ShowStatus(IStatusMessage message, StatusContext context) => null!;

public IAsyncAction HideStatus(IStatusMessage message) => null!;

public IAsyncAction LogMessage(ILogMessage message) => null!;
}

// A host that supports navigation and records what it was asked to do.
private sealed class NavigatingHost : IExtensionHost2
{
public ICommand? LastPage { get; private set; }

public NavigationMode LastMode { get; private set; }

public int GoToPageCallCount { get; private set; }

public IAsyncAction ShowStatus(IStatusMessage message, StatusContext context) => null!;

public IAsyncAction HideStatus(IStatusMessage message) => null!;

public IAsyncAction LogMessage(ILogMessage message) => null!;

public IAsyncOperation<IAuthorizationResult> RequestAuthorizationAsync(IAuthorizationRequest request) => null!;

public IAsyncAction GoToPageAsync(ICommand page, NavigationMode navigationMode)
{
LastPage = page;
LastMode = navigationMode;
GoToPageCallCount++;
return Task.CompletedTask.AsAsyncAction();
}
}

[TestMethod]
public void SupportsNavigation_FalseForLegacyHost()
{
ExtensionHost.Initialize(new LegacyHost());

Assert.IsFalse(ExtensionHost.SupportsNavigation);
}

[TestMethod]
public void SupportsNavigation_TrueForHost2()
{
ExtensionHost.Initialize(new NavigatingHost());

Assert.IsTrue(ExtensionHost.SupportsNavigation);
}

[TestMethod]
public async Task GoToPageAsync_LegacyHost_ThrowsNotSupported()
{
ExtensionHost.Initialize(new LegacyHost());

await Assert.ThrowsExceptionAsync<System.NotSupportedException>(
() => ExtensionHost.GoToPageAsync(new Command()));
}

[TestMethod]
public async Task GoToPageAsync_NullPage_ThrowsArgumentNull()
{
ExtensionHost.Initialize(new NavigatingHost());

await Assert.ThrowsExceptionAsync<System.ArgumentNullException>(
() => ExtensionHost.GoToPageAsync(null!));
}

[TestMethod]
public async Task GoToPageAsync_ForwardsPageAndMode()
{
var host = new NavigatingHost();
ExtensionHost.Initialize(host);

var page = new Command();

await ExtensionHost.GoToPageAsync(page, NavigationMode.GoHome);

Assert.AreEqual(1, host.GoToPageCallCount);
Assert.AreSame(page, host.LastPage);
Assert.AreEqual(NavigationMode.GoHome, host.LastMode);
}

[TestMethod]
public async Task GoToPageAsync_DefaultsToPush()
{
var host = new NavigatingHost();
ExtensionHost.Initialize(host);

await ExtensionHost.GoToPageAsync(new Command());

Assert.AreEqual(NavigationMode.Push, host.LastMode);
}
}
Loading