Add support for showing status messages in the host (#281)

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 <zadjii@gmail.com>
This commit is contained in:
Mike Griese
2025-01-17 05:43:17 -06:00
committed by GitHub
parent 7da291e398
commit 5873cacabe
31 changed files with 822 additions and 136 deletions

View File

@@ -29,7 +29,8 @@ internal sealed partial class SampleListPage : ListPage
Text = "Sample Tag",
}
],
}
},
new ListItem(new SendMessageCommand()) { Title = "I send messages" },
];
}
}

View File

@@ -10,6 +10,7 @@
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPalExtensions\$(RootNamespace)</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateAppxPackageOnBuild>true</GenerateAppxPackageOnBuild>
</PropertyGroup>
<ItemGroup>

View File

@@ -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"

View File

@@ -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<LogMessageViewModel> LogMessages { get; } = new();
public ObservableCollection<StatusMessageViewModel> 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;
}
}

View File

@@ -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<ICommandProvider> _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<CommandProviderWrapper, ItemsChangedEventArgs>? 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<ICommandItem[]>(_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<ICommandItem[]>(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)
{

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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<IListItem> _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();
}
}

View File

@@ -23,7 +23,7 @@ public partial class MainListPage : DynamicListPage,
{
private readonly IServiceProvider _serviceProvider;
private readonly ObservableCollection<TopLevelCommandWrapper> _commands;
private readonly ObservableCollection<TopLevelCommandItemWrapper> _commands;
private IEnumerable<IListItem>? _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;
}

View File

@@ -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) =>

View File

@@ -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);
}

View File

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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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<ILogMessage> _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;
}
}

View File

@@ -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);
}

View File

@@ -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<StatusMessageViewModel> 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

View File

@@ -36,7 +36,7 @@ public partial class SettingsViewModel : PageViewModel
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = [];
public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler)
: base(null, scheduler)
: base(null, scheduler, CommandPaletteHost.Instance)
{
_settings = settings;
_serviceProvider = serviceProvider;

View File

@@ -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<IStatusMessage> _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);
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public partial class TopLevelCommandItemWrapper : ListItem
{
public ExtensionObject<ICommandItem> 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<ICommandItem> 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<TopLevelCommandWrapper> FromExtension(ExtensionObject<ICommandItem>)`
// or a
// `async Task PromoteStub(ExtensionObject<ICommandItem>)`
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)
{
}
}
}

View File

@@ -31,7 +31,7 @@ public partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
}
public ObservableCollection<TopLevelCommandWrapper> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelCommandItemWrapper> 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<TopLevelCommandWrapper> clone = [.. TopLevelCommands];
List<TopLevelCommandWrapper> newItems = [];
List<TopLevelCommandItemWrapper> clone = [.. TopLevelCommands];
List<TopLevelCommandItemWrapper> 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)
{

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public partial class TopLevelCommandWrapper : ListItem
public partial class TopLevelCommandWrapper : ICommand
{
public ExtensionObject<ICommandItem> Model { get; }
private readonly ExtensionObject<ICommand> _command;
private readonly bool _isFallback;
public event TypedEventHandler<object, PropChangedEventArgs>? 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<ICommandItem> 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<TopLevelCommandWrapper> FromExtension(ExtensionObject<ICommandItem>)`
// or a
// `async Task PromoteStub(ExtensionObject<ICommandItem>)`
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)
{
}
}
}

View File

@@ -21,6 +21,13 @@
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
<cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" />
<StackLayout
x:Name="VerticalStackLayout"
Orientation="Vertical"
Spacing="4" />
<!-- Template for actions in the mode actions dropdown button -->
<DataTemplate x:Key="ContextMenuViewModelTemplate" x:DataType="viewmodels:CommandContextItemViewModel">
<ListViewItem KeyDown="ActionListViewItem_KeyDown" Tapped="ActionListViewItem_Tapped">
@@ -55,15 +62,49 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
x:Name="IconBorder"
Width="16"
Height="16"
<Grid
x:Name="IconRoot"
Margin="8,0,0,0"
x:Load="{x:Bind IsLoaded, Mode=OneWay}"
CornerRadius="{StaticResource ControlCornerRadius}"
SourceKey="{x:Bind CurrentPageViewModel.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
Tapped="PageIcon_Tapped">
<cpcontrols:IconBox
x:Name="IconBorder"
Width="16"
Height="16"
x:Load="{x:Bind IsLoaded, Mode=OneWay}"
CornerRadius="{StaticResource ControlCornerRadius}"
SourceKey="{x:Bind CurrentPageViewModel.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
<InfoBadge Visibility="{x:Bind CurrentPageViewModel.HasStatusMessage, Mode=OneWay}" Value="{x:Bind CurrentPageViewModel.StatusMessages.Count, Mode=OneWay}" />
<Grid.ContextFlyout>
<Flyout x:Name="StatusMessagesFlyout" Placement="TopEdgeAlignedLeft">
<ItemsRepeater
x:Name="MessagesDropdown"
Margin="-8"
ItemsSource="{x:Bind CurrentPageViewModel.StatusMessages, Mode=OneWay}"
Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="viewmodels:StatusMessageViewModel">
<StackPanel
Grid.Row="0"
Margin="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
CornerRadius="0">
<InfoBar
CornerRadius="{ThemeResource ControlCornerRadius}"
IsClosable="False"
IsOpen="True"
Message="{x:Bind Message, Mode=OneWay}"
Severity="{x:Bind State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" />
</StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Flyout>
</Grid.ContextFlyout>
</Grid>
<TextBlock
Grid.Column="1"

View File

@@ -103,4 +103,14 @@ public sealed partial class ActionBar : UserControl,
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")]
private void SecondaryButton_Tapped(object sender, TappedRoutedEventArgs e) =>
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
private void PageIcon_Tapped(object sender, TappedRoutedEventArgs e)
{
if (CurrentPageViewModel?.StatusMessages.Count > 0)
{
StatusMessagesFlyout.ShowAt(
placementTarget: IconRoot,
showOptions: new FlyoutShowOptions() { ShowMode = FlyoutShowMode.Standard });
}
}
}

View File

@@ -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();

View File

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

View File

@@ -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" />
<converters:BoolNegationConverter x:Key="BoolNegationConverter" />
<cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" />
<converters:BoolToVisibilityConverter
x:Key="BoolToInvertedVisibilityConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
</ResourceDictionary>
</Page.Resources>
@@ -212,6 +218,31 @@
</ScrollView>
</Grid>
<!--
Horrifying: You may ask yourself - why is there a Background on this InfoBar?
Well, as it turns out, the Informational InfoBar has a transparent
background. It just cannot be bothered. So, we need to manually give
it one to actually obscure the text beneath it. And you can't just give
the InfoBar itself a Background, because then the other Severity's
won't get colorized.
See https://github.com/microsoft/microsoft-ui-xaml/issues/5741
-->
<StackPanel
Grid.Row="0"
Margin="16,8,16,8"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
CornerRadius="{ThemeResource ControlCornerRadius}">
<InfoBar
CornerRadius="{ThemeResource ControlCornerRadius}"
IsOpen="{x:Bind ViewModel.CurrentPage.HasStatusMessage, Mode=OneWay}"
Message="{x:Bind ViewModel.CurrentPage.MostRecentStatusMessage.Message, Mode=OneWay}"
Severity="{x:Bind ViewModel.CurrentPage.MostRecentStatusMessage.State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" />
</StackPanel>
<cpcontrols:ActionBar Grid.Row="1" CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
<VisualStateManager.VisualStateGroups>

View File

@@ -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;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ShellPage :
Page,
public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<PerformCommandMessage>,
IRecipient<OpenSettingsMessage>,
@@ -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(),
};

View File

@@ -43,7 +43,7 @@ public partial class CommandItem : BaseObservable, ICommandItem
}
}
public ICommand? Command
public virtual ICommand? Command
{
get => _command;
set

View File

@@ -15,13 +15,18 @@ public class ExtensionHost
_host = host;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="message">The log message to send</param>
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)
{
}
});
}
}
}

View File

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