diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs index ce42487fe158..c6735a638933 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs @@ -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 @@ -13,6 +15,8 @@ private sealed class EmptyCommandProviderContext : ICommandProviderContext public string ProviderId => ""; public bool SupportsPinning => false; + + public ICommandItem? GetCommandItem(string id) => null; } } @@ -21,4 +25,6 @@ public interface ICommandProviderContext string ProviderId { get; } bool SupportsPinning { get; } + + ICommandItem? GetCommandItem(string id); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index ce5cbcf69d99..d67ddea43a8a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -22,6 +22,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext private readonly ExtensionObject _commandProvider; + private readonly ICommandProvider4? _commandProvider4; + private readonly TaskScheduler _taskScheduler; private readonly ICommandProviderCache? _commandProviderCache; @@ -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); @@ -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); @@ -102,6 +106,7 @@ public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThr } _commandProvider = new(provider); + _commandProvider4 = provider as ICommandProvider4; try { @@ -123,6 +128,7 @@ public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThr } isValid = true; + SupportsPinning = _commandProvider4 is not null; } private ProviderSettings GetProviderSettings(SettingsModel settings) @@ -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); } @@ -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(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoToPageMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoToPageMessage.cs new file mode 100644 index 000000000000..1091065074c5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoToPageMessage.cs @@ -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); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs index 832bd22d8cce..624ad66c5042 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs @@ -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 command) { Command = command; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs index 2220b836e5ca..9c5f83a6d128 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs @@ -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); @@ -338,7 +338,7 @@ private void PerformCommand(PerformCommandMessage message) CoreLogger.LogDebug($"Invoking command"); WeakReferenceMessenger.Default.Send(); - StartInvoke(message, invokable, host); + StartInvoke(message, invokable, host, providerContext); } } catch (Exception ex) @@ -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) @@ -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(); @@ -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; @@ -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) { @@ -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. @@ -475,7 +489,7 @@ private void UnsafeHandleCommandResult(ICommandResult? result) if (result.Args is IToastArgs a) { WeakReferenceMessenger.Default.Send(new(a.Message)); - UnsafeHandleCommandResult(a.Result); + UnsafeHandleCommandResult(a.Result, host, providerContext, transientPage); } break; @@ -483,6 +497,59 @@ private void UnsafeHandleCommandResult(ICommandResult? result) } } + 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(targetCommand), new ExtensionObject(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(); @@ -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) @@ -528,6 +595,7 @@ public void CancelNavigation() public void Dispose() { + WeakReferenceMessenger.Default.UnregisterAll(this); _handleInvokeTask?.Dispose(); _navigationCts?.Dispose(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 6d67743f0b4a..972a24b8ce84 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -46,6 +46,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IRecipient, IRecipient, IRecipient, + IRecipient, IRecipient, IRecipient, IRecipient, @@ -98,6 +99,7 @@ public ShellPage() WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -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 () => @@ -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(); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ShellViewModelTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ShellViewModelTests.cs new file mode 100644 index 000000000000..e9b6cf804f57 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ShellViewModelTests.cs @@ -0,0 +1,197 @@ +// 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.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class ShellViewModelTests +{ + private sealed partial class TestAppExtensionHost(string displayName) : AppExtensionHost + { + public override string? GetExtensionDisplayName() => displayName; + } + + private sealed class TestCommandProviderContext(string providerId) : ICommandProviderContext + { + private readonly Dictionary _items = []; + + public string ProviderId { get; } = providerId; + + public bool SupportsPinning => true; + + public int GetCommandItemCalls { get; private set; } + + public string? LastRequestedId { get; private set; } + + public void Add(ICommandItem item) + { + Assert.IsNotNull(item.Command); + _items[item.Command.Id] = item; + } + + public ICommandItem? GetCommandItem(string id) + { + GetCommandItemCalls++; + LastRequestedId = id; + + return _items.TryGetValue(id, out var item) ? item : null; + } + } + + private sealed class InitializedPageViewModel : PageViewModel + { + public InitializedPageViewModel(IPage model, TaskScheduler scheduler, AppExtensionHost extensionHost, ICommandProviderContext providerContext) + : base(model, scheduler, extensionHost, providerContext) + { + IsInitialized = true; + } + } + + public sealed class GoToPageMessageSink : IRecipient + { + public GoToPageMessage? Received { get; private set; } + + public void Receive(GoToPageMessage message) + { + Received = message; + } + } + + [TestMethod] + public void PerformCommand_UsesOverrideHostAndProviderContext() + { + var defaultHost = new TestAppExtensionHost("Default"); + var overrideHost = new TestAppExtensionHost("Override"); + var overrideProviderContext = new TestCommandProviderContext("override-provider"); + + var rootPageService = new Mock(MockBehavior.Strict); + rootPageService + .Setup(service => service.OnPerformCommand(null, true, overrideHost)); + + var pageViewModelFactory = new Mock(MockBehavior.Strict); + AppExtensionHost? capturedHost = null; + ICommandProviderContext? capturedProviderContext = null; + pageViewModelFactory + .Setup(factory => factory.TryCreatePageViewModel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IPage page, bool _, AppExtensionHost host, ICommandProviderContext providerContext) => + { + capturedHost = host; + capturedProviderContext = providerContext; + return new InitializedPageViewModel(page, TaskScheduler.Default, host, providerContext); + }); + + var appHostService = new Mock(MockBehavior.Strict); + appHostService.Setup(service => service.GetDefaultHost()).Returns(defaultHost); + + var viewModel = new ShellViewModel(TaskScheduler.Default, rootPageService.Object, pageViewModelFactory.Object, appHostService.Object); + try + { + var targetPage = new ListPage + { + Id = "target.page", + Name = "Target Page", + Title = "Target Page", + }; + + var message = new PerformCommandMessage(new ExtensionObject(targetPage)) + { + HostOverride = overrideHost, + ProviderContextOverride = overrideProviderContext, + }; + + viewModel.Receive(message); + + Assert.AreSame(overrideHost, capturedHost); + Assert.AreSame(overrideProviderContext, capturedProviderContext); + + pageViewModelFactory.VerifyAll(); + rootPageService.VerifyAll(); + appHostService.Verify(service => service.GetHostForCommand(It.IsAny(), It.IsAny()), Times.Never); + appHostService.Verify(service => service.GetProviderContextForCommand(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + viewModel.Dispose(); + } + } + + [TestMethod] + public void HandleCommandResult_GoToPage_ResolvesTargetAndSendsMessage() + { + var defaultHost = new TestAppExtensionHost("Default"); + var currentHost = new TestAppExtensionHost("Current"); + var providerContext = new TestCommandProviderContext("provider"); + + var targetPage = new ListPage + { + Id = "target.page", + Name = "Target Page", + Title = "Target Page", + }; + var targetItem = new CommandItem(targetPage) + { + Title = "Target Page", + }; + providerContext.Add(targetItem); + + var rootPageService = new Mock(MockBehavior.Loose); + var pageViewModelFactory = new Mock(MockBehavior.Loose); + var appHostService = new Mock(MockBehavior.Strict); + appHostService.Setup(service => service.GetDefaultHost()).Returns(defaultHost); + + var sink = new GoToPageMessageSink(); + WeakReferenceMessenger.Default.Register(sink); + + var viewModel = new ShellViewModel(TaskScheduler.Default, rootPageService.Object, pageViewModelFactory.Object, appHostService.Object); + try + { + viewModel.CurrentPage = new InitializedPageViewModel( + new ListPage + { + Id = "current.page", + Name = "Current Page", + Title = "Current Page", + }, + TaskScheduler.Default, + currentHost, + providerContext) + { + IsRootPage = false, + }; + + var result = CommandResult.GoToPage( + new GoToPageArgs + { + PageId = targetPage.Id, + NavigationMode = NavigationMode.GoBack, + }); + + viewModel.Receive(new HandleCommandResultMessage(new ExtensionObject(result))); + + Assert.AreEqual(1, providerContext.GetCommandItemCalls); + Assert.AreEqual(targetPage.Id, providerContext.LastRequestedId); + Assert.IsNotNull(sink.Received); + Assert.AreEqual(NavigationMode.GoBack, sink.Received.NavigationMode); + Assert.AreSame(currentHost, sink.Received.CommandMessage.HostOverride); + Assert.AreSame(providerContext, sink.Received.CommandMessage.ProviderContextOverride); + Assert.AreSame(targetPage, sink.Received.CommandMessage.Command.Unsafe); + Assert.AreSame(targetItem, sink.Received.CommandMessage.Context); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(sink); + viewModel.Dispose(); + } + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleNavigationPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleNavigationPage.cs new file mode 100644 index 000000000000..ed5e071b8b13 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleNavigationPage.cs @@ -0,0 +1,259 @@ +// 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; +using Microsoft.CommandPalette.Extensions.Toolkit; + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1649 // File name should match first type name +namespace SamplePagesExtension; + +internal static class SampleNavigationCommandCatalog +{ + public static ListItem CreateRootListItem() => + new(new SampleNavigationPage()) + { + Title = "Navigation results and GoToPage", + Subtitle = "Shows GoBack, GoHome, and GoToPage working together", + }; + + public static ListItem CreatePlaygroundListItem() => + new(new SampleNavigationPlaygroundPage()) + { + Title = "Open a nested navigation playground", + Subtitle = "Adds one more page to the stack so GoBack-oriented samples are easier to see", + }; + + public static ICommandItem GetCommandItem(string id) => id switch + { + SampleNavigationPage.CommandId => CreateRootListItem(), + SampleNavigationPlaygroundPage.CommandId => CreateCommandItem( + new SampleNavigationPlaygroundPage(), + "Nested navigation playground", + "Adds another page to the stack before trying GoToPage"), + SampleNavigationTargetListPage.CommandId => CreateCommandItem( + new SampleNavigationTargetListPage(), + "GoToPage target list page", + "A list page resolved by ICommandProvider4.GetCommandItem"), + SampleNavigationTargetContentPage.CommandId => CreateCommandItem( + new SampleNavigationTargetContentPage(), + "GoToPage target content page", + "A content page resolved by ICommandProvider4.GetCommandItem"), + _ => null, + }; + + private static CommandItem CreateCommandItem(ICommand command, string title, string subtitle) => + new(command) + { + Title = title, + Subtitle = subtitle, + }; +} + +internal sealed partial class SampleNavigationPage : ListPage +{ + public const string CommandId = "sample.navigation.root"; + + public SampleNavigationPage() + { + Id = CommandId; + Name = "Navigation results"; + Title = "Navigation results and GoToPage"; + Icon = new IconInfo("\uE8AB"); + } + + public override IListItem[] GetItems() => + [ + ResultItem( + "Return CommandResult.GoBack()", + "Pop back to the previous page in the shell stack", + CommandResult.GoBack()), + ResultItem( + "Return CommandResult.GoHome()", + "Jump all the way back to the palette home page", + CommandResult.GoHome()), + SampleNavigationCommandCatalog.CreatePlaygroundListItem(), + ResultItem( + "Return GoToPage(Push) to a list page", + "Resolve the target by id and push it on top of the current page", + CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetListPage.CommandId, + NavigationMode = NavigationMode.Push, + })), + ResultItem( + "Return GoToPage(GoHome) to a content page", + "Go home first, then navigate to the resolved content page", + CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetContentPage.CommandId, + NavigationMode = NavigationMode.GoHome, + })), + ]; + + private static ListItem ResultItem(string title, string subtitle, ICommandResult result) => + new(new AnonymousCommand(() => { }) + { + Name = title, + Result = result, + }) + { + Title = title, + Subtitle = subtitle, + }; +} + +internal sealed partial class SampleNavigationPlaygroundPage : ListPage +{ + public const string CommandId = "sample.navigation.playground"; + + public SampleNavigationPlaygroundPage() + { + Id = CommandId; + Name = "Nested navigation playground"; + Title = "Nested navigation playground"; + Icon = new IconInfo("\uE8AB"); + } + + public override IListItem[] GetItems() => + [ + new ListItem(new NoOpCommand()) + { + Title = "This page gives GoBack-oriented samples something to unwind", + Subtitle = "Try the GoToPage(GoBack) item below to pop back once and then open a new target page", + }, + ResultItem( + "Return GoToPage(GoBack) to a list page", + "Go back one level, then navigate to the resolved list page", + CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetListPage.CommandId, + NavigationMode = NavigationMode.GoBack, + })), + ResultItem( + "Return GoToPage(Push) to a content page", + "Stay on this stack and push a resolved content page on top", + CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetContentPage.CommandId, + NavigationMode = NavigationMode.Push, + })), + ResultItem( + "Return CommandResult.GoBack()", + "Pop back to the previous sample page without resolving anything", + CommandResult.GoBack()), + ResultItem( + "Return CommandResult.GoHome()", + "Leave the sample flow and jump to the palette home page", + CommandResult.GoHome()), + ]; + + private static ListItem ResultItem(string title, string subtitle, ICommandResult result) => + new(new AnonymousCommand(() => { }) + { + Name = title, + Result = result, + }) + { + Title = title, + Subtitle = subtitle, + }; +} + +internal sealed partial class SampleNavigationTargetListPage : ListPage +{ + public const string CommandId = "sample.navigation.target.list"; + + public SampleNavigationTargetListPage() + { + Id = CommandId; + Name = "GoToPage target list page"; + Title = "GoToPage target list page"; + Icon = new IconInfo("\uE8FD"); + } + + public override IListItem[] GetItems() => + [ + new ListItem(new NoOpCommand()) + { + Title = "You landed on a list page resolved through GetCommandItem", + Subtitle = "This page was not opened directly from the visible samples list", + }, + new ListItem(new AnonymousCommand(() => { }) + { + Name = "Push content target", + Result = CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetContentPage.CommandId, + NavigationMode = NavigationMode.Push, + }), + }) + { + Title = "Push the content target with GoToPage", + Subtitle = "Resolve another sample page by id and push it on top of this one", + }, + new ListItem(new AnonymousCommand(() => { }) + { + Name = "GoBack", + Result = CommandResult.GoBack(), + }) + { + Title = "Return CommandResult.GoBack()", + Subtitle = "Go back to whatever page navigated here", + }, + ]; +} + +internal sealed partial class SampleNavigationTargetContentPage : ContentPage +{ + public const string CommandId = "sample.navigation.target.content"; + + private readonly MarkdownContent _markdown = new() + { + Body = """ +# GoToPage target content page + +This page was resolved by id through `ICommandProvider4.GetCommandItem`. + +- `Push` keeps the existing stack and places this page on top. +- `GoBack` unwinds once before opening the resolved page. +- `GoHome` clears back to the palette home page first, then opens this page. +""", + }; + + public SampleNavigationTargetContentPage() + { + Id = CommandId; + Name = "GoToPage target content page"; + Title = "GoToPage target content page"; + Icon = new IconInfo("\uE8A5"); + + Commands = + [ + new CommandContextItem( + title: "Go back", + name: "Go back", + subtitle: "Return CommandResult.GoBack()", + result: CommandResult.GoBack()), + new CommandContextItem( + title: "Go home", + name: "Go home", + subtitle: "Return CommandResult.GoHome()", + result: CommandResult.GoHome()), + new CommandContextItem( + title: "Push the list target", + name: "Push the list target", + subtitle: "Return GoToPage(Push) to the list target page", + result: CommandResult.GoToPage(new GoToPageArgs + { + PageId = SampleNavigationTargetListPage.CommandId, + NavigationMode = NavigationMode.Push, + })), + ]; + } + + public override IContent[] GetContent() => [_markdown]; +} +#pragma warning restore SA1402 // File may only contain a single type +#pragma warning restore SA1649 // File name should match first type name diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs index d24d4964d38f..77deff5ea909 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs @@ -29,13 +29,26 @@ public override ICommandItem[] TopLevelCommands() return _commands; } - public override ICommandItem[] GetDockBands() + public override ICommandItem GetCommandItem(string id) { - List bands = new() + foreach (var command in _commands) { + if (command.Command is { } topLevelCommand && topLevelCommand.Id == id) + { + return command; + } + } + + return SampleNavigationCommandCatalog.GetCommandItem(id); + } + + public override ICommandItem[] GetDockBands() + { + List bands = + [ new SampleDockBand(), - new SampleButtonsDockBand(), - }; + new SampleButtonsDockBand() + ]; return bands.ToArray(); } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 3dd67086c8b6..a88caceb776a 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -49,6 +49,7 @@ public partial class SamplesListPage : ListPage Title = "Demo of OnLoad/OnUnload", Subtitle = "Changes the list of items every time the page is opened / closed", }, + SampleNavigationCommandCatalog.CreateRootListItem(), new ListItem(new SampleIconPage()) { Title = "Sample Icon Page",