From 5873cacabed645307c72ea0056cd9847e4bd69e7 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Fri, 17 Jan 2025 05:43:17 -0600 Subject: [PATCH] Add support for showing status messages in the host (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is a ton of _plumbing_. UX-wise, this is very rough. What's more important in this PR is the broad wiring this does, to connect individual pages with the `IExtension` that's hosting them. `CommandPaletteHost` is the important new class that we're introducing. This is the class that implements the `IExtensionHost` interface, and is the one by which extensions can use to log messages back to the host. There's both: * A singleton instance of the `CommandPaletteHost`, which represents all global state, * per-extension instances of the `CommandPaletteHost`, which allows us to know which extension a message came from. When we fetch a command provider, we'll create a new `CommandPaletteHost` for that extension, and connect the extension to that instance. * Log messages from an extension go to the global list of messages, so those go to the global instance's list of `LogMessageViewModel`s * When an extension writes status messages, we'll add the messages to _that extension's_ `CommandPaletteHost`. * The `PageContext` is aware of the `CommandPaletteHost`, so it can now retrieve information about the hosting extension for that page. Since all pages for an extension share a single `CommandPaletteHost`, status messages can be shown across all the pages in that extension's context, then hidden when the user leaves that context. This also does part of #253, because now we have a `TopLevelCommandWrapper` AND a `TopLevelCommandItemWrapper`, separately. That lets us store the `CommandPaletteHost` in the `TopLevelCommandWrapper`, which we need so that when we activate a top-level command, we can fetch the extension host out of it and give it to the pages that follow. Also included is the "single builtin command provider" which is also in #264, because it's kinda insane to have things like "Quit", "Reload extension", "View log", things which are all _core pieces of the palette itself_, each need a separate provider. That's insane. I didn't add support for: * Extensions to hide messages once they're shown * I dunno if `PropChanged`'ing a status message works * I didn't add support for progress bars yet, because it's NOT TRIVIAL to replace the icon of an InfoBar with a progress wheel. What the heck WinUI 😠 * Again, this is Programmer Xaml - we'll need real designers to come around and clean this up --------- Co-authored-by: Mike Griese --- .../Pages/SampleListPage.cs | 3 +- .../SamplePagesExtension.csproj | 1 + src/modules/cmdpal/Invoke-XamlFormat.ps1 | 5 + .../CommandPaletteHost.cs | 119 +++++++++++++++++ .../CommandProviderWrapper.cs | 75 ++++++++--- .../Commands/BuiltInsCommandProvider.cs | 4 +- .../Commands/FallbackLogItem.cs | 28 ++++ .../Commands/LogMessagesPage.cs | 48 +++++++ .../Commands/MainListPage.cs | 4 +- .../ExtensionObjectViewModel.cs | 21 +-- .../FormsPageViewModel.cs | 4 +- .../GlobalLogPageContext.cs | 19 +++ .../ListViewModel.cs | 4 +- .../LoadingPageViewModel.cs | 2 +- .../LogMessageViewModel.cs | 34 +++++ .../MarkdownPageViewModel.cs | 4 +- .../PageViewModel.cs | 36 ++++- .../SettingsViewModel.cs | 2 +- .../StatusMessageViewModel.cs | 72 ++++++++++ .../TopLevelCommandItemWrapper.cs | 123 ++++++++++++++++++ .../TopLevelCommandManager.cs | 20 ++- .../TopLevelCommandWrapper.cs | 96 ++++---------- .../Controls/ActionBar.xaml | 57 ++++++-- .../Controls/ActionBar.xaml.cs | 10 ++ .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 1 + .../MessageStateToSeverityConverter.xaml.cs | 37 ++++++ .../cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml | 31 +++++ .../Microsoft.CmdPal.UI/ShellPage.xaml.cs | 31 +++-- .../CommandItem.cs | 2 +- .../ExtensionHost.cs | 36 ++++- .../Pages/SendMessageCommand.cs | 29 +++++ 31 files changed, 822 insertions(+), 136 deletions(-) create mode 100644 src/modules/cmdpal/Invoke-XamlFormat.ps1 create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandItemWrapper.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/MessageStateToSeverityConverter.xaml.cs create mode 100644 src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs index e231663477..2743154d00 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs @@ -29,7 +29,8 @@ internal sealed partial class SampleListPage : ListPage Text = "Sample Tag", } ], - } + }, + new ListItem(new SendMessageCommand()) { Title = "I send messages" }, ]; } } diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj index 8063c58c50..ca40ffc8b2 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj @@ -10,6 +10,7 @@ $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPalExtensions\$(RootNamespace) false false + true diff --git a/src/modules/cmdpal/Invoke-XamlFormat.ps1 b/src/modules/cmdpal/Invoke-XamlFormat.ps1 new file mode 100644 index 0000000000..88dbeb51c2 --- /dev/null +++ b/src/modules/cmdpal/Invoke-XamlFormat.ps1 @@ -0,0 +1,5 @@ +$gitRoot = git rev-parse --show-toplevel + +# $xamlFilesForStyler = (git ls-files "$gitRoot/**/*.xaml") -join "," +$xamlFilesForStyler = (git ls-files "$gitRoot/src/modules/cmdpal/**/*.xaml") -join "," +dotnet tool run xstyler -- -c "$gitRoot\src\Settings.XamlStyler" -f "$xamlFilesForStyler" \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs new file mode 100644 index 0000000000..02be57a5a1 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs @@ -0,0 +1,119 @@ +// 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.ObjectModel; +using System.Diagnostics; +using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public sealed partial class CommandPaletteHost : IExtensionHost +{ + // Static singleton, so that we can access this from anywhere + // Post MVVM - this should probably be like, a dependency injection thing. + public static CommandPaletteHost Instance { get; } = new(); + + private static readonly GlobalLogPageContext _globalLogPageContext = new(); + + private ulong _hostHwnd; + + public ulong HostingHwnd => _hostHwnd; + + public string LanguageOverride => string.Empty; + + public static ObservableCollection LogMessages { get; } = new(); + + public ObservableCollection StatusMessages { get; } = new(); + + private readonly IExtensionWrapper? _source; + + public IExtensionWrapper? Extension => _source; + + private CommandPaletteHost() + { + } + + public CommandPaletteHost(IExtensionWrapper source) + { + _source = source; + } + + public IAsyncAction ShowStatus(IStatusMessage message) + { + Debug.WriteLine(message.Message); + + _ = Task.Run(() => + { + ProcessStatusMessage(message); + }); + + return Task.CompletedTask.AsAsyncAction(); + } + + public IAsyncAction HideStatus(IStatusMessage message) + { + return Task.CompletedTask.AsAsyncAction(); + } + + public IAsyncAction LogMessage(ILogMessage message) + { + Debug.WriteLine(message.Message); + + _ = Task.Run(() => + { + ProcessLogMessage(message); + }); + + // We can't just make a LogMessageViewModel : ExtensionObjectViewModel + // because we don't necessarily know the page context. Butts. + return Task.CompletedTask.AsAsyncAction(); + } + + public void ProcessLogMessage(ILogMessage message) + { + var vm = new LogMessageViewModel(message, _globalLogPageContext); + vm.SafeInitializePropertiesSynchronous(); + + if (_source != null) + { + vm.ExtensionPfn = _source.PackageFamilyName; + } + + Task.Factory.StartNew( + () => + { + LogMessages.Add(vm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + } + + public void ProcessStatusMessage(IStatusMessage message) + { + var vm = new StatusMessageViewModel(message, _globalLogPageContext); + vm.SafeInitializePropertiesSynchronous(); + + if (_source != null) + { + vm.ExtensionPfn = _source.PackageFamilyName; + } + + Task.Factory.StartNew( + () => + { + StatusMessages.Add(vm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + } + + public void SetHostHwnd(ulong hostHwnd) + { + _hostHwnd = hostHwnd; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index c8b7cdd9ab..7e02c09c9b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -2,8 +2,10 @@ // 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.Diagnostics; using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Models; using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; @@ -14,7 +16,7 @@ public sealed class CommandProviderWrapper private readonly bool isValid; - private readonly ICommandProvider _commandProvider; + private readonly ExtensionObject _commandProvider; private readonly IExtensionWrapper? extensionWrapper; @@ -22,22 +24,31 @@ public sealed class CommandProviderWrapper public IFallbackCommandItem[] FallbackItems { get; private set; } = []; + public string DisplayName { get; private set; } = string.Empty; + + public IExtensionWrapper? Extension => extensionWrapper; + + public CommandPaletteHost ExtensionHost { get; private set; } + public event TypedEventHandler? CommandsChanged; public string Id { get; private set; } = string.Empty; - public string DisplayName { get; private set; } = string.Empty; - public IconInfoViewModel Icon { get; private set; } = new(null); public string ProviderId => $"{extensionWrapper?.PackageFamilyName ?? string.Empty}/{Id}"; - public IExtensionWrapper? Extension => extensionWrapper; - public CommandProviderWrapper(ICommandProvider provider) { - _commandProvider = provider; - _commandProvider.ItemsChanged += CommandProvider_ItemsChanged; + // This ctor is only used for in-proc builtin commands. So the Unsafe! + // calls are pretty dang safe actually. + _commandProvider = new(provider); + + // Hook the extension back into us + ExtensionHost = CommandPaletteHost.Instance; + _commandProvider.Unsafe!.InitializeWithHost(ExtensionHost); + + _commandProvider.Unsafe!.ItemsChanged += CommandProvider_ItemsChanged; isValid = true; Id = provider.Id; @@ -49,6 +60,7 @@ public sealed class CommandProviderWrapper public CommandProviderWrapper(IExtensionWrapper extension) { extensionWrapper = extension; + ExtensionHost = new CommandPaletteHost(extension); if (!extensionWrapper.IsRunning()) { throw new ArgumentException("You forgot to start the extension. This is a coding error - make sure to call StartExtensionAsync"); @@ -61,14 +73,25 @@ public sealed class CommandProviderWrapper throw new ArgumentException("extension didn't actually implement ICommandProvider"); } - _commandProvider = provider; + _commandProvider = new(provider); try { - _commandProvider.ItemsChanged += CommandProvider_ItemsChanged; + var model = _commandProvider.Unsafe!; + + // Hook the extension back into us + model.InitializeWithHost(ExtensionHost); + model.ItemsChanged += CommandProvider_ItemsChanged; + + DisplayName = model.DisplayName; + + isValid = true; } - catch + catch (Exception e) { + Debug.WriteLine("Failed to initialize CommandProvider for extension."); + Debug.WriteLine($"Extension was {extensionWrapper!.PackageFamilyName}"); + Debug.WriteLine(e); } isValid = true; @@ -81,17 +104,31 @@ public sealed class CommandProviderWrapper return; } - var t = new Task(_commandProvider.TopLevelCommands); - t.Start(); - var commands = await t.ConfigureAwait(false); + ICommandItem[]? commands = null; + IFallbackCommandItem[]? fallbacks = null; - // On a BG thread here - var fallbacks = _commandProvider.FallbackCommands(); + try + { + var model = _commandProvider.Unsafe!; - Id = _commandProvider.Id; - DisplayName = _commandProvider.DisplayName; - Icon = new(_commandProvider.Icon); - Icon.InitializeProperties(); + var t = new Task(model.TopLevelCommands); + t.Start(); + commands = await t.ConfigureAwait(false); + + // On a BG thread here + fallbacks = model.FallbackCommands(); + + Id = model.Id; + DisplayName = model.DisplayName; + Icon = new(model.Icon); + Icon.InitializeProperties(); + } + catch (Exception e) + { + Debug.WriteLine("Failed to load commands from extension"); + Debug.WriteLine($"Extension was {extensionWrapper!.PackageFamilyName}"); + Debug.WriteLine(e); + } if (commands != null) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs index 85e44d94eb..301140496d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs @@ -16,16 +16,18 @@ public partial class BuiltInsCommandProvider : CommandProvider private readonly OpenSettingsCommand openSettings = new(); private readonly QuitAction quitAction = new(); private readonly FallbackReloadItem _fallbackReloadItem = new(); + private readonly FallbackLogItem _fallbackLogItem = new(); public override ICommandItem[] TopLevelCommands() => [ - new CommandItem(openSettings) { Subtitle = "Open Command Palette settings" } + new CommandItem(openSettings) { Subtitle = "Open Command Palette settings" }, ]; public override IFallbackCommandItem[] FallbackCommands() => [ new FallbackCommandItem(quitAction) { Subtitle = "Exit Command Palette" }, _fallbackReloadItem, + _fallbackLogItem, ]; public BuiltInsCommandProvider() diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs new file mode 100644 index 0000000000..3b8510de99 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs @@ -0,0 +1,28 @@ +// 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.CmdPal.Extensions.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Commands; + +namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; + +internal sealed partial class FallbackLogItem : FallbackCommandItem +{ + private readonly LogMessagesPage _logMessagesPage; + + public FallbackLogItem() + : base(new LogMessagesPage()) + { + _logMessagesPage = (LogMessagesPage)Command!; + Title = string.Empty; + _logMessagesPage.Name = string.Empty; + Subtitle = "View log messages"; + } + + public override void UpdateQuery(string query) + { + _logMessagesPage.Name = query.StartsWith('l') ? "View log" : string.Empty; + Title = _logMessagesPage.Name; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs new file mode 100644 index 0000000000..b9c640ac61 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs @@ -0,0 +1,48 @@ +// 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.Specialized; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace Microsoft.CmdPal.UI.ViewModels.Commands; + +public partial class LogMessagesPage : ListPage +{ + private readonly List _listItems = new(); + + public LogMessagesPage() + { + Name = "View log"; + Title = "Log"; + Icon = new("\uE8FD"); + CommandPaletteHost.LogMessages.CollectionChanged += LogMessages_CollectionChanged; + } + + private void LogMessages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null) + { + foreach (var item in e.NewItems) + { + if (item is LogMessageViewModel logMessageViewModel) + { + var li = new ListItem(new NoOpCommand()) + { + Title = logMessageViewModel.Message, + Subtitle = logMessageViewModel.ExtensionPfn, + }; + _listItems.Insert(0, li); + } + } + + RaiseItemsChanged(_listItems.Count); + } + } + + public override IListItem[] GetItems() + { + return _listItems.ToArray(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index bbfeba4348..44cb1f7016 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -23,7 +23,7 @@ public partial class MainListPage : DynamicListPage, { private readonly IServiceProvider _serviceProvider; - private readonly ObservableCollection _commands; + private readonly ObservableCollection _commands; private IEnumerable? _filteredItems; @@ -161,7 +161,7 @@ public partial class MainListPage : DynamicListPage, } var isFallback = false; - if (topLevelOrAppItem is TopLevelCommandWrapper toplevel) + if (topLevelOrAppItem is TopLevelCommandItemWrapper toplevel) { isFallback = toplevel.IsFallback; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs index e11b75bf14..4c94980068 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs @@ -19,19 +19,24 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject { var t = new Task(() => { - try - { - InitializeProperties(); - } - catch (Exception ex) - { - PageContext.ShowException(ex); - } + SafeInitializePropertiesSynchronous(); }); t.Start(); await t; } + public void SafeInitializePropertiesSynchronous() + { + try + { + InitializeProperties(); + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + public abstract void InitializeProperties(); protected void UpdateProperty(string propertyName) => diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FormsPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FormsPageViewModel.cs index b28fcaa82b..27f91fdb13 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FormsPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FormsPageViewModel.cs @@ -19,8 +19,8 @@ public partial class FormsPageViewModel : PageViewModel // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] - public FormsPageViewModel(IFormPage model, TaskScheduler scheduler) - : base(model, scheduler) + public FormsPageViewModel(IFormPage model, TaskScheduler scheduler, CommandPaletteHost host) + : base(model, scheduler, host) { _model = new(model); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs new file mode 100644 index 0000000000..fde1a36817 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs @@ -0,0 +1,19 @@ +// 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. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public class GlobalLogPageContext : IPageContext +{ + public TaskScheduler Scheduler { get; private init; } + + public void ShowException(Exception ex, string? extensionHint) + { /*do nothing*/ + } + + public GlobalLogPageContext() + { + Scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index 18feafcbd7..9311977c9e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -39,8 +39,8 @@ public partial class ListViewModel : PageViewModel private bool _isDynamic; - public ListViewModel(IListPage model, TaskScheduler scheduler) - : base(model, scheduler) + public ListViewModel(IListPage model, TaskScheduler scheduler, CommandPaletteHost host) + : base(model, scheduler, host) { _model = new(model); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs index 92cd6c82d1..fb732eef06 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class LoadingPageViewModel : PageViewModel { public LoadingPageViewModel(IPage? model, TaskScheduler scheduler) - : base(model, scheduler) + : base(model, scheduler, CommandPaletteHost.Instance) { ModelIsLoading = true; IsInitialized = false; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs new file mode 100644 index 0000000000..7b4ed88e30 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs @@ -0,0 +1,34 @@ +// 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.CmdPal.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class LogMessageViewModel : ExtensionObjectViewModel +{ + private readonly ExtensionObject _model; + + public string Message { get; private set; } = string.Empty; + + public string ExtensionPfn { get; set; } = string.Empty; + + public LogMessageViewModel(ILogMessage message, IPageContext context) + : base(context) + { + _model = new(message); + } + + public override void InitializeProperties() + { + var model = _model.Unsafe; + if (model == null) + { + return; // throw? + } + + Message = model.Message; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs index bb72b5e1fd..3d134805a2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs @@ -29,8 +29,8 @@ public partial class MarkdownPageViewModel : PageViewModel // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] - public MarkdownPageViewModel(IMarkdownPage model, TaskScheduler scheduler) - : base(model, scheduler) + public MarkdownPageViewModel(IMarkdownPage model, TaskScheduler scheduler, CommandPaletteHost host) + : base(model, scheduler, host) { _model = new(model); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs index e263936662..1a9b599d08 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs @@ -2,6 +2,7 @@ // 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.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.CmdPal.Extensions; @@ -31,6 +32,17 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext [ObservableProperty] public partial string Filter { get; set; } = string.Empty; + [ObservableProperty] + public partial CommandPaletteHost ExtensionHost { get; private set; } + + public bool HasStatusMessage => MostRecentStatusMessage != null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusMessage))] + public partial StatusMessageViewModel? MostRecentStatusMessage { get; private set; } = null; + + public ObservableCollection StatusMessages => ExtensionHost.StatusMessages; + // These are properties that are "observable" from the extension object // itself, in the sense that they get raised by PropChanged events from the // extension. However, we don't want to actually make them @@ -47,13 +59,35 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public IconInfoViewModel Icon { get; protected set; } - public PageViewModel(IPage? model, TaskScheduler scheduler) + public PageViewModel(IPage? model, TaskScheduler scheduler, CommandPaletteHost extensionHost) : base(null) { _pageModel = new(model); Scheduler = scheduler; PageContext = this; + ExtensionHost = extensionHost; Icon = new(null); + + ExtensionHost.StatusMessages.CollectionChanged += StatusMessages_CollectionChanged; + UpdateHasStatusMessage(); + } + + private void StatusMessages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + UpdateHasStatusMessage(); + } + + private void UpdateHasStatusMessage() + { + if (ExtensionHost.StatusMessages.Any()) + { + var last = ExtensionHost.StatusMessages.Last(); + MostRecentStatusMessage = last; + } + else + { + MostRecentStatusMessage = null; + } } //// Run on background thread from ListPage.xaml.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 034e31a07f..a7bfa785c6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -36,7 +36,7 @@ public partial class SettingsViewModel : PageViewModel public ObservableCollection CommandProviders { get; } = []; public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler) - : base(null, scheduler) + : base(null, scheduler, CommandPaletteHost.Instance) { _settings = settings; _serviceProvider = serviceProvider; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs new file mode 100644 index 0000000000..700681f2b7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs @@ -0,0 +1,72 @@ +// 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.CmdPal.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class StatusMessageViewModel : ExtensionObjectViewModel +{ + private readonly ExtensionObject _model; + + public string Message { get; private set; } = string.Empty; + + public MessageState State { get; private set; } = MessageState.Info; + + public string ExtensionPfn { get; set; } = string.Empty; + + public StatusMessageViewModel(IStatusMessage message, IPageContext context) + : base(context) + { + _model = new(message); + } + + public override void InitializeProperties() + { + var model = _model.Unsafe; + if (model == null) + { + return; // throw? + } + + Message = model.Message; + State = model.State; + + model.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + FetchProperty(args.PropertyName); + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + + protected virtual void FetchProperty(string propertyName) + { + var model = this._model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(Message): + this.Message = model.Message; + break; + case nameof(State): + this.State = model.State; + break; + } + + UpdateProperty(propertyName); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandItemWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandItemWrapper.cs new file mode 100644 index 0000000000..f635575142 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandItemWrapper.cs @@ -0,0 +1,123 @@ +// 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.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// +/// Abstraction of a top-level command. Currently owns just a live ICommandItem +/// from an extension (or in-proc command provider), but in the future will +/// also support stub top-level items. +/// +public partial class TopLevelCommandItemWrapper : ListItem +{ + public ExtensionObject Model { get; } + + public bool IsFallback { get; private set; } + + public string Id { get; private set; } = string.Empty; + + // public override ICommand? Command { get => base.Command; set => base.Command = value; } + private readonly TopLevelCommandWrapper _topLevelCommand; + + public CommandPaletteHost? ExtensionHost { get => _topLevelCommand.ExtensionHost; set => _topLevelCommand.ExtensionHost = value; } + + public TopLevelCommandItemWrapper(ExtensionObject commandItem, bool isFallback) + : base(new TopLevelCommandWrapper(commandItem.Unsafe?.Command ?? new NoOpCommand())) + { + _topLevelCommand = (TopLevelCommandWrapper)this.Command!; + + IsFallback = isFallback; + + // TODO: In reality, we should do an async fetch when we're created + // from an extension object. Probably have an + // `static async Task FromExtension(ExtensionObject)` + // or a + // `async Task PromoteStub(ExtensionObject)` + Model = commandItem; + try + { + var model = Model.Unsafe; + if (model == null) + { + return; + } + + _topLevelCommand.UnsafeInitializeProperties(); + + Id = _topLevelCommand.Id; + + Title = model.Title; + Subtitle = model.Subtitle; + Icon = model.Icon; + MoreCommands = model.MoreCommands; + + model.PropChanged += Model_PropChanged; + _topLevelCommand.PropChanged += Model_PropChanged; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine(ex); + } + } + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + var propertyName = args.PropertyName; + var model = Model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(_topLevelCommand.Name): + case nameof(Title): + this.Title = model.Title; + break; + case nameof(Subtitle): + this.Subtitle = model.Subtitle; + break; + case nameof(Icon): + var listIcon = model.Icon; + Icon = model.Icon; + break; + + // TODO! MoreCommands array, which needs to also raise HasMoreCommands + } + } + catch + { + } + } + + public void TryUpdateFallbackText(string newQuery) + { + if (!IsFallback) + { + return; + } + + try + { + _ = Task.Run(() => + { + var model = Model.Unsafe; + if (model is IFallbackCommandItem fallback) + { + fallback.FallbackHandler.UpdateQuery(newQuery); + } + }); + } + catch (Exception) + { + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 9abdba5815..513f08e6d6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -31,7 +31,7 @@ public partial class TopLevelCommandManager : ObservableObject, WeakReferenceMessenger.Default.Register(this); } - public ObservableCollection TopLevelCommands { get; set; } = []; + public ObservableCollection TopLevelCommands { get; set; } = []; [ObservableProperty] public partial bool IsLoading { get; private set; } = true; @@ -64,12 +64,20 @@ public partial class TopLevelCommandManager : ObservableObject, { foreach (var i in commandProvider.TopLevelItems) { - TopLevelCommands.Add(new(new(i), false)); + TopLevelCommandItemWrapper wrapper = new(new(i), false) + { + ExtensionHost = commandProvider.ExtensionHost, + }; + TopLevelCommands.Add(wrapper); } foreach (var i in commandProvider.FallbackItems) { - TopLevelCommands.Add(new(new(i), true)); + TopLevelCommandItemWrapper wrapper = new(new(i), true) + { + ExtensionHost = commandProvider.ExtensionHost, + }; + TopLevelCommands.Add(wrapper); } }, CancellationToken.None, @@ -99,8 +107,8 @@ public partial class TopLevelCommandManager : ObservableObject, { // Work on a clone of the list, so that we can just do one atomic // update to the actual observable list at the end - List clone = [.. TopLevelCommands]; - List newItems = []; + List clone = [.. TopLevelCommands]; + List newItems = []; var startIndex = -1; var firstCommand = sender.TopLevelItems[0]; var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length; @@ -202,7 +210,7 @@ public partial class TopLevelCommandManager : ObservableObject, return true; } - public TopLevelCommandWrapper? LookupCommand(string id) + public TopLevelCommandItemWrapper? LookupCommand(string id) { foreach (var command in TopLevelCommands) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandWrapper.cs index b08d3dd095..8a24b6c443 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandWrapper.cs @@ -3,58 +3,42 @@ // See the LICENSE file in the project root for more information. using Microsoft.CmdPal.Extensions; -using Microsoft.CmdPal.Extensions.Helpers; using Microsoft.CmdPal.UI.ViewModels.Models; +using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; -/// -/// Abstraction of a top-level command. Currently owns just a live ICommandItem -/// from an extension (or in-proc command provider), but in the future will -/// also support stub top-level items. -/// -public partial class TopLevelCommandWrapper : ListItem +public partial class TopLevelCommandWrapper : ICommand { - public ExtensionObject Model { get; } + private readonly ExtensionObject _command; - private readonly bool _isFallback; + public event TypedEventHandler? PropChanged; + + public string Name { get; private set; } = string.Empty; public string Id { get; private set; } = string.Empty; - public bool IsFallback => _isFallback; + public IconInfo Icon { get; private set; } = new(null); - public TopLevelCommandWrapper(ExtensionObject commandItem, bool isFallback) - : base(commandItem.Unsafe?.Command ?? new NoOpCommand()) + public ICommand Command => _command.Unsafe!; + + public CommandPaletteHost? ExtensionHost { get; set; } + + public TopLevelCommandWrapper(ICommand command) { - _isFallback = isFallback; + _command = new(command); + } - // TODO: In reality, we should do an async fetch when we're created - // from an extension object. Probably have an - // `static async Task FromExtension(ExtensionObject)` - // or a - // `async Task PromoteStub(ExtensionObject)` - Model = commandItem; - try - { - var model = Model.Unsafe; - if (model == null) - { - return; - } + public void UnsafeInitializeProperties() + { + var model = _command.Unsafe!; - Id = model.Command?.Id ?? string.Empty; + Name = model.Name; + Id = model.Id; + Icon = model.Icon; - Title = model.Title; - Subtitle = model.Subtitle; - Icon = model.Icon; - MoreCommands = model.MoreCommands; - - model.PropChanged += Model_PropChanged; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine(ex); - } + model.PropChanged += Model_PropChanged; + model.PropChanged += this.PropChanged; } private void Model_PropChanged(object sender, PropChangedEventArgs args) @@ -62,7 +46,7 @@ public partial class TopLevelCommandWrapper : ListItem try { var propertyName = args.PropertyName; - var model = Model.Unsafe; + var model = _command.Unsafe; if (model == null) { return; // throw? @@ -70,45 +54,19 @@ public partial class TopLevelCommandWrapper : ListItem switch (propertyName) { - case nameof(Title): - this.Title = model.Title; - break; - case nameof(Subtitle): - this.Subtitle = model.Subtitle; + case nameof(Name): + this.Name = model.Name; break; case nameof(Icon): var listIcon = model.Icon; Icon = model.Icon; break; - - // TODO! MoreCommands array, which needs to also raise HasMoreCommands } + + PropChanged?.Invoke(this, args); } catch { } } - - // This is only ever called on a background thread, by - // MainListPage::UpdateSearchText, which is already running in the - // background. So x-proc calls we do in here are okay. - public void TryUpdateFallbackText(string newQuery) - { - if (!_isFallback) - { - return; - } - - try - { - var model = Model.Unsafe; - if (model is IFallbackCommandItem fallback) - { - fallback.FallbackHandler.UpdateQuery(newQuery); - } - } - catch (Exception) - { - } - } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml index e8b56755f6..eee01baaaf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml @@ -21,6 +21,13 @@ EmptyValue="Collapsed" NotEmptyValue="Visible" /> + + + + @@ -55,15 +62,49 @@ - + Tapped="PageIcon_Tapped"> + + + + + + + + + + + + + + + + + WeakReferenceMessenger.Default.Send(); + + private void PageIcon_Tapped(object sender, TappedRoutedEventArgs e) + { + if (CurrentPageViewModel?.StatusMessages.Count > 0) + { + StatusMessagesFlyout.ShowAt( + placementTarget: IconRoot, + showOptions: new FlyoutShowOptions() { ShowMode = FlyoutShowMode.Standard }); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index fa8c2b217c..bbfdf918f1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -43,6 +43,7 @@ public sealed partial class MainWindow : Window, InitializeComponent(); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); + CommandPaletteHost.Instance.SetHostHwnd((ulong)_hwnd.Value); PositionCentered(); SetAcrylic(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MessageStateToSeverityConverter.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MessageStateToSeverityConverter.xaml.cs new file mode 100644 index 0000000000..412ccbad05 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MessageStateToSeverityConverter.xaml.cs @@ -0,0 +1,37 @@ +// 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.CmdPal.Extensions; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI; + +public partial class MessageStateToSeverityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is MessageState state) + { + switch (state) + { + case MessageState.Info: + return InfoBarSeverity.Informational; + case MessageState.Success: + return InfoBarSeverity.Success; + case MessageState.Warning: + return InfoBarSeverity.Warning; + case MessageState.Error: + return InfoBarSeverity.Error; + } + } + + return InfoBarSeverity.Informational; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml index d58577c3ca..af07ba1d20 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml @@ -4,6 +4,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:animations="using:CommunityToolkit.WinUI.Animations" + xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:converters="using:CommunityToolkit.WinUI.Converters" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" @@ -20,11 +21,16 @@ x:Key="StringNotEmptyToVisibilityConverter" EmptyValue="Collapsed" NotEmptyValue="Visible" /> + + + + + @@ -212,6 +218,31 @@ + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs index 47e8bf866e..74bc19f387 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.MainPage; using Microsoft.CmdPal.UI.ViewModels.Messages; @@ -20,8 +21,7 @@ namespace Microsoft.CmdPal.UI; /// /// An empty page that can be used on its own or navigated to within a Frame. /// -public sealed partial class ShellPage : - Page, +public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IRecipient, IRecipient, IRecipient, @@ -91,9 +91,24 @@ public sealed partial class ShellPage : // Or the command may be a stub. Future us problem. try { - // This could be navigation to another page or invoking of a command, those are our two main branches of logic here. - // For different pages, we may construct different view models and navigate to the central frame to different pages, - // Otherwise the logic is mostly the same, outside the main page. + var host = ViewModel.CurrentPage?.ExtensionHost ?? CommandPaletteHost.Instance; + + if (command is TopLevelCommandWrapper wrapper) + { + var tlc = wrapper; + command = wrapper.Command; + host = tlc.ExtensionHost != null ? tlc.ExtensionHost! : host; +#if DEBUG + if (tlc.ExtensionHost?.Extension != null) + { + host.ProcessLogMessage(new LogMessage() + { + Message = $"Activated top-level command from {tlc.ExtensionHost.Extension.ExtensionDisplayName}", + }); + } +#endif + } + if (command is IPage page) { _ = _queue.TryEnqueue(() => @@ -106,12 +121,12 @@ public sealed partial class ShellPage : // Construct our ViewModel of the appropriate type and pass it the UI Thread context. PageViewModel pageViewModel = page switch { - IListPage listPage => new ListViewModel(listPage, _mainTaskScheduler) + IListPage listPage => new ListViewModel(listPage, _mainTaskScheduler, host) { IsNested = !isMainPage, }, - IFormPage formsPage => new FormsPageViewModel(formsPage, _mainTaskScheduler), - IMarkdownPage markdownPage => new MarkdownPageViewModel(markdownPage, _mainTaskScheduler), + IFormPage formsPage => new FormsPageViewModel(formsPage, _mainTaskScheduler, host), + IMarkdownPage markdownPage => new MarkdownPageViewModel(markdownPage, _mainTaskScheduler, host), _ => throw new NotSupportedException(), }; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandItem.cs index 21140871aa..1a6a24144e 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandItem.cs @@ -43,7 +43,7 @@ public partial class CommandItem : BaseObservable, ICommandItem } } - public ICommand? Command + public virtual ICommand? Command { get => _command; set diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs index 86ef0d3354..4bd85a75c7 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs @@ -15,13 +15,18 @@ public class ExtensionHost _host = host; } + /// + /// Fire-and-forget a log message to the Command Palette host app. Since + /// the host is in another process, we do this in a try/catch in a + /// background thread, as to not block the calling thread, nor explode if + /// the host app is gone. + /// + /// The log message to send public static void LogMessage(ILogMessage message) { - // TODO this feels like bad async if (Host != null) { - // really just fire-and-forget - new Task(async () => + _ = Task.Run(async () => { try { @@ -30,7 +35,30 @@ public class ExtensionHost catch (Exception) { } - }).Start(); + }); + } + } + + public static void LogMessage(string message) + { + var logMessage = new LogMessage() { Message = message }; + LogMessage(logMessage); + } + + public static void ShowStatus(IStatusMessage message) + { + if (Host != null) + { + _ = Task.Run(async () => + { + try + { + await Host.ShowStatus(message); + } + catch (Exception) + { + } + }); } } } diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs new file mode 100644 index 0000000000..6b741ea847 --- /dev/null +++ b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs @@ -0,0 +1,29 @@ +// 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.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +internal sealed partial class SendMessageCommand : InvokableCommand +{ + private static int sentMessages; + + public override ICommandResult Invoke() + { + var kind = MessageState.Info; + switch (sentMessages % 4) + { + case 0: kind = MessageState.Info; break; + case 1: kind = MessageState.Success; break; + case 2: kind = MessageState.Warning; break; + case 3: kind = MessageState.Error; break; + } + + var message = new StatusMessage() { Message = $"I am status message no.{sentMessages++}", State = kind }; + ExtensionHost.ShowStatus(message); + return CommandResult.KeepOpen(); + } +}