A different approach to the extension host problem

I think the work to figure out the extension host was much
overcomplicated. I don't think we need all that.
This commit is contained in:
Mike Griese
2025-07-10 12:53:38 -05:00
parent ae6172dc16
commit e214cda85c
14 changed files with 263 additions and 219 deletions

View File

@@ -0,0 +1,174 @@
// 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 ManagedCommon;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Core.ViewModels;
public abstract partial class AppExtensionHost : IExtensionHost
{
private static readonly GlobalLogPageContext _globalLogPageContext = new();
private static ulong _hostingHwnd;
public static ObservableCollection<LogMessageViewModel> LogMessages { get; } = [];
public ulong HostingHwnd => _hostingHwnd;
public string LanguageOverride => string.Empty;
public ObservableCollection<StatusMessageViewModel> StatusMessages { get; } = [];
public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd;
public void DebugLog(string message)
{
#if DEBUG
this.ProcessLogMessage(new LogMessage(message));
#endif
}
public IAsyncAction HideStatus(IStatusMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
_ = Task.Run(() =>
{
ProcessHideStatusMessage(message);
});
return Task.CompletedTask.AsAsyncAction();
}
public void Log(string message)
{
this.ProcessLogMessage(new LogMessage(message));
}
public IAsyncAction LogMessage(ILogMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Logger.LogDebug(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 ProcessHideStatusMessage(IStatusMessage message)
{
Task.Factory.StartNew(
() =>
{
try
{
var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (vm != null)
{
StatusMessages.Remove(vm);
}
}
catch
{
}
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessLogMessage(ILogMessage message)
{
var vm = new LogMessageViewModel(message, _globalLogPageContext);
vm.SafeInitializePropertiesSynchronous();
// if (Extension != null)
// {
// vm.ExtensionPfn = Extension.PackageFamilyName;
// }
Task.Factory.StartNew(
() =>
{
LogMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessStatusMessage(IStatusMessage message, StatusContext context)
{
// If this message is already in the list of messages, just bring it to the top
var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (oldVm != null)
{
Task.Factory.StartNew(
() =>
{
StatusMessages.Remove(oldVm);
StatusMessages.Add(oldVm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
return;
}
var vm = new StatusMessageViewModel(message, new(_globalLogPageContext));
vm.SafeInitializePropertiesSynchronous();
// if (Extension != null)
// {
// vm.ExtensionPfn = Extension.PackageFamilyName;
// }
Task.Factory.StartNew(
() =>
{
StatusMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Debug.WriteLine(message.Message);
_ = Task.Run(() =>
{
ProcessStatusMessage(message, context);
});
return Task.CompletedTask.AsAsyncAction();
}
public abstract string? GetExtensionDisplayName();
}
public interface IAppHostService
{
AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost);
}

View File

@@ -2,34 +2,17 @@
// 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 ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Core.ViewModels;
public sealed partial class CommandPaletteHost : IExtensionHost
public sealed partial class CommandPaletteHost : AppExtensionHost, 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 static ulong _hostingHwnd;
public ulong HostingHwnd => _hostingHwnd;
public string LanguageOverride => string.Empty;
public static ObservableCollection<LogMessageViewModel> LogMessages { get; } = [];
public ObservableCollection<StatusMessageViewModel> StatusMessages { get; } = [];
public IExtensionWrapper? Extension { get; }
private readonly ICommandProvider? _builtInProvider;
@@ -48,145 +31,8 @@ public sealed partial class CommandPaletteHost : IExtensionHost
_builtInProvider = builtInProvider;
}
public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
public override string? GetExtensionDisplayName()
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Debug.WriteLine(message.Message);
_ = Task.Run(() =>
{
ProcessStatusMessage(message, context);
});
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction HideStatus(IStatusMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
_ = Task.Run(() =>
{
ProcessHideStatusMessage(message);
});
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction LogMessage(ILogMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Logger.LogDebug(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 (Extension != null)
{
vm.ExtensionPfn = Extension.PackageFamilyName;
}
Task.Factory.StartNew(
() =>
{
LogMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessStatusMessage(IStatusMessage message, StatusContext context)
{
// If this message is already in the list of messages, just bring it to the top
var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (oldVm != null)
{
Task.Factory.StartNew(
() =>
{
StatusMessages.Remove(oldVm);
StatusMessages.Add(oldVm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
return;
}
var vm = new StatusMessageViewModel(message, new(_globalLogPageContext));
vm.SafeInitializePropertiesSynchronous();
if (Extension != null)
{
vm.ExtensionPfn = Extension.PackageFamilyName;
}
Task.Factory.StartNew(
() =>
{
StatusMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessHideStatusMessage(IStatusMessage message)
{
Task.Factory.StartNew(
() =>
{
try
{
var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (vm != null)
{
StatusMessages.Remove(vm);
}
}
catch
{
}
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd;
public void DebugLog(string message)
{
#if DEBUG
this.ProcessLogMessage(new LogMessage(message));
#endif
}
public void Log(string message)
{
this.ProcessLogMessage(new LogMessage(message));
return Extension?.ExtensionDisplayName;
}
}

View File

@@ -47,7 +47,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host)
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
_model = new(model);
@@ -111,15 +111,15 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
Commands = model.Commands
.ToList()
.Select(item =>
.Select<IContextItem, IContextItemViewModel>(item =>
{
if (item is CommandContextItem contextItem)
{
return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel;
return new CommandContextItemViewModel(contextItem, PageContext);
}
else
{
return new SeparatorContextItemViewModel() as IContextItemViewModel;
return new SeparatorContextItemViewModel();
}
})
.ToList();
@@ -178,7 +178,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
}
else
{
return new SeparatorContextItemViewModel() as IContextItemViewModel;
return new SeparatorContextItemViewModel();
}
})
.ToList();

View File

@@ -72,7 +72,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
}
public ListViewModel(IListPage model, TaskScheduler scheduler, CommandPaletteHost host)
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
_model = new(model);
@@ -158,10 +158,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
// Cancel any ongoing search
if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
}
_cancellationTokenSource?.Cancel();
lock (_listLock)
{

View File

@@ -2,7 +2,6 @@
// 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.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -14,8 +13,7 @@ public partial class LogMessageViewModel : ExtensionObjectViewModel
public string Message { get; private set; } = string.Empty;
public string ExtensionPfn { get; set; } = string.Empty;
// public string ExtensionPfn { get; set; } = string.Empty;
public LogMessageViewModel(ILogMessage message, IPageContext context)
: base(context)
{

View File

@@ -43,7 +43,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter;
[ObservableProperty]
public partial CommandPaletteHost ExtensionHost { get; private set; }
public partial AppExtensionHost ExtensionHost { get; private set; }
public bool HasStatusMessage => MostRecentStatusMessage != null;
@@ -69,7 +69,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public IconInfoViewModel Icon { get; protected set; }
public PageViewModel(IPage? model, TaskScheduler scheduler, CommandPaletteHost extensionHost)
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost)
: base((IPageContext?)null)
{
_pageModel = new(model);
@@ -220,7 +220,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
{
// Set the extensionHint to the Page Title (if we have one, and one not provided).
// extensionHint ??= _pageModel?.Unsafe?.Title;
extensionHint ??= ExtensionHost.Extension?.ExtensionDisplayName ?? Title;
extensionHint ??= ExtensionHost.GetExtensionDisplayName() ?? Title;
Task.Factory.StartNew(
() =>
{
@@ -263,5 +263,5 @@ public interface IPageViewModelFactoryService
/// <param name="nested">Indicates whether the page is not the top-level page.</param>
/// <param name="host">The command palette host that will host the page (for status messages)</param>
/// <returns>A new instance of the page view model.</returns>
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, CommandPaletteHost host);
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host);
}

View File

@@ -15,7 +15,7 @@ public class PageViewModelFactory : IPageViewModelFactoryService
_scheduler = scheduler;
}
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, CommandPaletteHost host)
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host)
{
return page switch
{

View File

@@ -20,6 +20,7 @@ public partial class ShellViewModel : ObservableObject,
IRecipient<PerformCommandMessage>
{
private readonly IRootPageService _rootPageService;
private readonly IAppHostService _appHostService;
private readonly TaskScheduler _scheduler;
private readonly IPageViewModelFactoryService _pageViewModelFactory;
private readonly Lock _invokeLock = new();
@@ -66,7 +67,8 @@ public partial class ShellViewModel : ObservableObject,
public bool IsNested { get => _isNested; }
public ShellViewModel(TaskScheduler scheduler, IRootPageService rootPageService, IPageViewModelFactoryService pageViewModelFactory)
public ShellViewModel(
TaskScheduler scheduler, IRootPageService rootPageService, IPageViewModelFactoryService pageViewModelFactory, IAppHostService appHostService)
{
_pageViewModelFactory = pageViewModelFactory;
_scheduler = scheduler;
@@ -75,6 +77,7 @@ public partial class ShellViewModel : ObservableObject,
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
_appHostService = appHostService;
}
[RelayCommand]
@@ -204,48 +207,47 @@ public partial class ShellViewModel : ObservableObject,
// In the case that we're coming from a top-level command, the
// current page's host is the global instance. We only really want
// to use that as the host of last resort.
var pageHost = CurrentPage?.ExtensionHost;
if (pageHost == CommandPaletteHost.Instance)
{
pageHost = null;
}
var messageHost = message.ExtensionHost;
// Use the host from the current page if it has one, else use the
// one specified in the PerformMessage for a top-level command,
// else just use the global one.
CommandPaletteHost host;
// TODO! we need a different way to get the current CommandPaletteHost out of the command.
//// Top level items can come through without a Extension set on the
//// message. In that case, the `Context` is actually the
//// TopLevelViewModel itself, and we can use that to get at the
//// extension object.
// extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
// if (extension == null && message.Context is TopLevelViewModel topLevelViewModel)
// var pageHost = CurrentPage?.ExtensionHost;
// if (pageHost == CommandPaletteHost.Instance)
// {
// extension = topLevelViewModel.ExtensionHost?.Extension;
// host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance;
// pageHost = null;
// }
// else
// var messageHost = message.ExtensionHost;
//// Use the host from the current page if it has one, else use the
//// one specified in the PerformMessage for a top-level command,
//// else just use the global one.
// CommandPaletteHost host;
//// TODO! we need a different way to get the current CommandPaletteHost out of the command.
////// Top level items can come through without a Extension set on the
////// message. In that case, the `Context` is actually the
////// TopLevelViewModel itself, and we can use that to get at the
////// extension object.
//// extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
//// if (extension == null && message.Context is TopLevelViewModel topLevelViewModel)
//// {
//// extension = topLevelViewModel.ExtensionHost?.Extension;
//// host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance;
//// }
//// else
//// {
//// host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
//// }
// host = CommandPaletteHost.Instance;
// if (extension != null)
// {
// host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
// if (messageHost != null)
// {
// Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
// }
// else
// {
// Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
// }
// }
host = CommandPaletteHost.Instance;
if (extension != null)
{
if (messageHost != null)
{
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
}
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
}
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage?.ExtensionHost);
SetActiveExtension(extension);
if (command is IPage page)

View File

@@ -15,8 +15,6 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel
public MessageState State { get; private set; } = MessageState.Info;
public string ExtensionPfn { get; set; } = string.Empty;
public ProgressViewModel? Progress { get; private set; }
public bool HasProgress => Progress != null;

View File

@@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandPaletteContentPageViewModel : ContentPageViewModel
{
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host)
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
}

View File

@@ -17,7 +17,7 @@ public class CommandPalettePageViewModelFactory
_scheduler = scheduler;
}
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, CommandPaletteHost host)
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host)
{
return page switch
{

View File

@@ -32,7 +32,6 @@ public partial class LogMessagesPage : ListPage
var li = new ListItem(new NoOpCommand())
{
Title = logMessageViewModel.Message,
Subtitle = logMessageViewModel.ExtensionPfn,
};
_listItems.Insert(0, li);
}

View File

@@ -146,6 +146,7 @@ public partial class App : Application
services.AddSingleton<TrayIconService>();
services.AddSingleton<IRootPageService, PowerToysRootPageService>();
services.AddSingleton<IAppHostService, PowerToysAppHostService>();
services.AddSingleton(new TelemetryForwarder());
// ViewModels

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 ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Microsoft.CmdPal.UI;
internal sealed class PowerToysAppHostService : IAppHostService
{
public AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost)
{
AppExtensionHost? topLevelHost = null;
if (context is TopLevelViewModel topLevelViewModel)
{
topLevelHost = topLevelViewModel.ExtensionHost;
}
return topLevelHost ?? currentHost ?? CommandPaletteHost.Instance;
}
}