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(); + } +}