CmdPal: Fix SUI crash ; Allow extensions to be disabled (#38040)

Both `TopLevelCommandItemWrapper` and `TopLevelViewModel` were really the same thing. The latter was from an earlier prototype, and the former is a more correct, safer abstraction. We really should have only ever used the former, but alas, we only used it for the SUI, and it piggy-backed off the latter, and that meant the latter's bugs became the former's.


tldr: I made the icon access safe in the SUI. 

And while I was doing this, because we now have a cleaner VM abstraction here in the host, we can actually cleanly disable extensions, because the `CommandProviderWrapper` knows which `ViewModel`s it made. 

Closes https://github.com/zadjii-msft/PowerToys/issues/426
Closes https://github.com/zadjii-msft/PowerToys/issues/478
Closes https://github.com/zadjii-msft/PowerToys/issues/577
This commit is contained in:
Mike Griese
2025-03-20 15:36:10 -05:00
committed by GitHub
parent 57cbcc2c3e
commit 14919dff10
34 changed files with 590 additions and 528 deletions

View File

@@ -24,9 +24,11 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Update="Assets\Run@2x.svg"> <None Remove="Assets\Run_V2_2x.svg" />
</ItemGroup>
<ItemGroup>
<Content Update="Assets\Run_V2_2x.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>

View File

@@ -15,7 +15,7 @@ internal sealed partial class ShellListPage : DynamicListPage
public ShellListPage(SettingsManager settingsManager) public ShellListPage(SettingsManager settingsManager)
{ {
Icon = new IconInfo("\uE756"); Icon = Icons.RunV2;
Id = "com.microsoft.cmdpal.shell"; Id = "com.microsoft.cmdpal.shell";
Name = Resources.cmd_plugin_name; Name = Resources.cmd_plugin_name;
PlaceholderText = Resources.list_placeholder_text; PlaceholderText = Resources.list_placeholder_text;

View File

@@ -115,7 +115,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Run as administrator (Ctrl+Shift+Enter). /// Looks up a localized string similar to Run as administrator.
/// </summary> /// </summary>
public static string cmd_run_as_administrator { public static string cmd_run_as_administrator {
get { get {
@@ -124,7 +124,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Run as different user (Ctrl+Shift+U). /// Looks up a localized string similar to Run as different user.
/// </summary> /// </summary>
public static string cmd_run_as_user { public static string cmd_run_as_user {
get { get {

View File

@@ -130,7 +130,7 @@
<value>execute command through command shell</value> <value>execute command through command shell</value>
</data> </data>
<data name="cmd_run_as_administrator" xml:space="preserve"> <data name="cmd_run_as_administrator" xml:space="preserve">
<value>Run as administrator (Ctrl+Shift+Enter)</value> <value>Run as administrator</value>
</data> </data>
<data name="cmd_command_failed" xml:space="preserve"> <data name="cmd_command_failed" xml:space="preserve">
<value>Error running the command</value> <value>Error running the command</value>
@@ -139,7 +139,7 @@
<value>Command not found</value> <value>Command not found</value>
</data> </data>
<data name="cmd_run_as_user" xml:space="preserve"> <data name="cmd_run_as_user" xml:space="preserve">
<value>Run as different user (Ctrl+Shift+U)</value> <value>Run as different user</value>
</data> </data>
<data name="leave_shell_open" xml:space="preserve"> <data name="leave_shell_open" xml:space="preserve">
<value>Keep shell open</value> <value>Keep shell open</value>

View File

@@ -18,6 +18,7 @@ public partial class SystemCommandExtensionProvider : CommandProvider
public SystemCommandExtensionProvider() public SystemCommandExtensionProvider()
{ {
DisplayName = Resources.Microsoft_plugin_ext_system_page_name; DisplayName = Resources.Microsoft_plugin_ext_system_page_name;
Id = "System";
_commands = [ _commands = [
new CommandItem(Page) new CommandItem(Page)
{ {

View File

@@ -22,7 +22,7 @@ public partial class TimeDateCommandsProvider : CommandProvider
public TimeDateCommandsProvider() public TimeDateCommandsProvider()
{ {
DisplayName = Resources.Microsoft_plugin_timedate_plugin_name; DisplayName = Resources.Microsoft_plugin_timedate_plugin_name;
Id = "DateTime";
_command = new CommandItem(_timeDateExtensionPage) _command = new CommandItem(_timeDateExtensionPage)
{ {
Icon = _timeDateExtensionPage.Icon, Icon = _timeDateExtensionPage.Icon,

View File

@@ -146,6 +146,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(Title)); UpdateProperty(nameof(Title));
UpdateProperty(nameof(Subtitle)); UpdateProperty(nameof(Subtitle));
UpdateProperty(nameof(Icon)); UpdateProperty(nameof(Icon));
// Load-bearing: if you don't raise a IsInitialized here, then
// TopLevelViewModel will never know what the command's ID is, so it
// will never be able to load Hotkeys & aliases
UpdateProperty(nameof(IsInitialized)); UpdateProperty(nameof(IsInitialized));
Initialized |= InitializedState.Initialized; Initialized |= InitializedState.Initialized;

View File

@@ -6,6 +6,8 @@ using ManagedCommon;
using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation; using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
@@ -20,9 +22,9 @@ public sealed class CommandProviderWrapper
private readonly TaskScheduler _taskScheduler; private readonly TaskScheduler _taskScheduler;
public ICommandItem[] TopLevelItems { get; private set; } = []; public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
public IFallbackCommandItem[] FallbackItems { get; private set; } = []; public TopLevelViewModel[] FallbackItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty; public string DisplayName { get; private set; } = string.Empty;
@@ -38,7 +40,13 @@ public sealed class CommandProviderWrapper
public CommandSettingsViewModel? Settings { get; private set; } public CommandSettingsViewModel? Settings { get; private set; }
public string ProviderId => $"{Extension?.PackageFamilyName ?? string.Empty}/{Id}"; public string ProviderId
{
get
{
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
}
}
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread) public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
{ {
@@ -105,13 +113,25 @@ public sealed class CommandProviderWrapper
isValid = true; isValid = true;
} }
public async Task LoadTopLevelCommands() private ProviderSettings GetProviderSettings(SettingsModel settings)
{
return settings.GetProviderSettings(this);
}
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{ {
if (!isValid) if (!isValid)
{ {
return; return;
} }
var settings = serviceProvider.GetService<SettingsModel>()!;
if (!GetProviderSettings(settings).IsEnabled)
{
return;
}
ICommandItem[]? commands = null; ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null; IFallbackCommandItem[]? fallbacks = null;
@@ -119,7 +139,7 @@ public sealed class CommandProviderWrapper
{ {
var model = _commandProvider.Unsafe!; var model = _commandProvider.Unsafe!;
var t = new Task<ICommandItem[]>(model.TopLevelCommands); Task<ICommandItem[]> t = new(model.TopLevelCommands);
t.Start(); t.Start();
commands = await t.ConfigureAwait(false); commands = await t.ConfigureAwait(false);
@@ -134,6 +154,8 @@ public sealed class CommandProviderWrapper
Settings = new(model.Settings, this, _taskScheduler); Settings = new(model.Settings, this, _taskScheduler);
Settings.InitializeProperties(); Settings.InitializeProperties();
InitializeCommands(commands, fallbacks, serviceProvider, pageContext);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})"); Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
} }
catch (Exception e) catch (Exception e)
@@ -142,15 +164,33 @@ public sealed class CommandProviderWrapper
Logger.LogError($"Extension was {Extension!.PackageFamilyName}"); Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString()); Logger.LogError(e.ToString());
} }
}
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, serviceProvider);
topLevelViewModel.ItemViewModel.SlowInitializeProperties();
return topLevelViewModel;
};
if (commands != null) if (commands != null)
{ {
TopLevelItems = commands; TopLevelItems = commands
.Select(c => makeAndAdd(c, false))
.ToArray();
} }
if (fallbacks != null) if (fallbacks != null)
{ {
FallbackItems = fallbacks; FallbackItems = fallbacks
.Select(c => makeAndAdd(c, true))
.ToArray();
} }
} }

View File

@@ -49,6 +49,7 @@ public partial class CommandViewModel : ExtensionObjectViewModel
return; return;
} }
Id = model.Id ?? string.Empty;
Name = model.Name ?? string.Empty; Name = model.Name ?? string.Empty;
IsFastInitialized = true; IsFastInitialized = true;
} }

View File

@@ -73,7 +73,6 @@ public partial class MainListPage : DynamicListPage,
{ {
return _tlcManager return _tlcManager
.TopLevelCommands .TopLevelCommands
.Select(tlc => tlc)
.Where(tlc => !string.IsNullOrEmpty(tlc.Title)) .Where(tlc => !string.IsNullOrEmpty(tlc.Title))
.ToArray(); .ToArray();
} }
@@ -167,16 +166,17 @@ public partial class MainListPage : DynamicListPage,
var id = IdForTopLevelOrAppItem(topLevelOrAppItem); var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var extensionDisplayName = string.Empty; var extensionDisplayName = string.Empty;
if (topLevelOrAppItem is TopLevelCommandItemWrapper toplevel) if (topLevelOrAppItem is TopLevelViewModel topLevel)
{ {
isFallback = toplevel.IsFallback; isFallback = topLevel.IsFallback;
if (toplevel.Alias?.Alias is string alias) if (topLevel.HasAlias)
{ {
var alias = topLevel.AliasText;
isAliasMatch = alias == query; isAliasMatch = alias == query;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase); isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
} }
extensionDisplayName = toplevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty; extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
} }
var nameMatch = StringMatcher.FuzzySearch(query, title); var nameMatch = StringMatcher.FuzzySearch(query, title);
@@ -221,9 +221,9 @@ public partial class MainListPage : DynamicListPage,
private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
{ {
if (topLevelOrAppItem is TopLevelCommandItemWrapper toplevel) if (topLevelOrAppItem is TopLevelViewModel topLevel)
{ {
return toplevel.Id; return topLevel.Id;
} }
else else
{ {

View File

@@ -9,7 +9,7 @@ using Windows.Storage.Streams;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class IconDataViewModel : ObservableObject public partial class IconDataViewModel : ObservableObject, IIconData
{ {
private readonly ExtensionObject<IIconData> _model = new(null); private readonly ExtensionObject<IIconData> _model = new(null);
@@ -25,6 +25,8 @@ public partial class IconDataViewModel : ObservableObject
// first. Hence why we're sticking this into an ExtensionObject // first. Hence why we're sticking this into an ExtensionObject
public ExtensionObject<IRandomAccessStreamReference> Data { get; private set; } = new(null); public ExtensionObject<IRandomAccessStreamReference> Data { get; private set; } = new(null);
IRandomAccessStreamReference? IIconData.Data => Data.Unsafe;
public IconDataViewModel(IIconData? icon) public IconDataViewModel(IIconData? icon)
{ {
_model = new(icon); _model = new(icon);

View File

@@ -8,7 +8,7 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class IconInfoViewModel : ObservableObject public partial class IconInfoViewModel : ObservableObject, IIconInfo
{ {
private readonly ExtensionObject<IIconInfo> _model = new(null); private readonly ExtensionObject<IIconInfo> _model = new(null);
@@ -28,6 +28,10 @@ public partial class IconInfoViewModel : ObservableObject
public bool IsSet => _model.Unsafe != null; public bool IsSet => _model.Unsafe != null;
IIconData? IIconInfo.Dark => Dark;
IIconData? IIconInfo.Light => Light;
public IconInfoViewModel(IIconInfo? icon) public IconInfoViewModel(IIconInfo? icon)
{ {
_model = new(icon); _model = new(icon);

View File

@@ -18,16 +18,19 @@ public record PerformCommandMessage
public bool WithAnimation { get; set; } = true; public bool WithAnimation { get; set; } = true;
public CommandPaletteHost? ExtensionHost { get; private set; }
public PerformCommandMessage(ExtensionObject<ICommand> command) public PerformCommandMessage(ExtensionObject<ICommand> command)
{ {
Command = command; Command = command;
Context = null; Context = null;
} }
public PerformCommandMessage(TopLevelCommandItemWrapper topLevelCommand) public PerformCommandMessage(TopLevelViewModel topLevelCommand)
{ {
Command = new(topLevelCommand.Command); Command = topLevelCommand.CommandViewModel.Model;
Context = null; Context = null;
ExtensionHost = topLevelCommand.ExtensionHost;
} }
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context) public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context)

View File

@@ -106,14 +106,14 @@ public class ExtensionWrapper : IExtensionWrapper
{ {
Logger.LogDebug($"Starting {ExtensionDisplayName} ({ExtensionClassId})"); Logger.LogDebug($"Starting {ExtensionDisplayName} ({ExtensionClassId})");
nint extensionPtr = nint.Zero; var extensionPtr = nint.Zero;
try try
{ {
// -2147024809: E_INVALIDARG // -2147024809: E_INVALIDARG
// -2147467262: E_NOINTERFACE // -2147467262: E_NOINTERFACE
// -2147024893: E_PATH_NOT_FOUND // -2147024893: E_PATH_NOT_FOUND
Guid guid = typeof(IExtension).GUID; var guid = typeof(IExtension).GUID;
global::Windows.Win32.Foundation.HRESULT hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out object? extensionObj); var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out var extensionObj);
if (hr.Value == -2147024893) if (hr.Value == -2147024893)
{ {
@@ -179,7 +179,7 @@ public class ExtensionWrapper : IExtensionWrapper
{ {
await StartExtensionAsync(); await StartExtensionAsync();
object? supportedProviders = GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]); var supportedProviders = GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]);
if (supportedProviders is IEnumerable<T> multipleProvidersSupported) if (supportedProviders is IEnumerable<T> multipleProvidersSupported)
{ {
return multipleProvidersSupported; return multipleProvidersSupported;

View File

@@ -303,6 +303,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Disabled.
/// </summary>
public static string builtin_disabled_extension {
get {
return ResourceManager.GetString("builtin_disabled_extension", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Built-in commands. /// Looks up a localized string similar to Built-in commands.
/// </summary> /// </summary>

View File

@@ -236,4 +236,7 @@
<data name="builtin_reload_display_title" xml:space="preserve"> <data name="builtin_reload_display_title" xml:space="preserve">
<value>Reload Command Palette extensions</value> <value>Reload Command Palette extensions</value>
</data> </data>
<data name="builtin_disabled_extension" xml:space="preserve">
<value>Disabled</value>
</data>
</root> </root>

View File

@@ -10,23 +10,14 @@ public class ProviderSettings
{ {
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
[JsonIgnore]
public string PackageFamilyName { get; set; } = string.Empty;
[JsonIgnore]
public string Id { get; set; } = string.Empty;
[JsonIgnore] [JsonIgnore]
public string ProviderDisplayName { get; set; } = string.Empty; public string ProviderDisplayName { get; set; } = string.Empty;
// Originally, I wanted to do:
// public string ProviderId => $"{PackageFamilyName}/{ProviderDisplayName}";
// but I think that's actually a bad idea, because the Display Name can be localized.
[JsonIgnore] [JsonIgnore]
public string ProviderId => $"{PackageFamilyName}/{Id}"; public string ProviderId { get; private set; } = string.Empty;
[JsonIgnore] [JsonIgnore]
public bool IsBuiltin => string.IsNullOrEmpty(PackageFamilyName); public bool IsBuiltin { get; private set; }
public ProviderSettings(CommandProviderWrapper wrapper) public ProviderSettings(CommandProviderWrapper wrapper)
{ {
@@ -41,10 +32,12 @@ public class ProviderSettings
public void Connect(CommandProviderWrapper wrapper) public void Connect(CommandProviderWrapper wrapper)
{ {
PackageFamilyName = wrapper.Extension?.PackageFamilyName ?? string.Empty; ProviderId = wrapper.ProviderId;
Id = wrapper.DisplayName; IsBuiltin = wrapper.Extension == null;
ProviderDisplayName = wrapper.DisplayName; ProviderDisplayName = wrapper.DisplayName;
if (ProviderId == "/")
if (string.IsNullOrEmpty(ProviderId))
{ {
throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!"); throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!");
} }

View File

@@ -4,7 +4,10 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
@@ -14,14 +17,13 @@ public partial class ProviderSettingsViewModel(
ProviderSettings _providerSettings, ProviderSettings _providerSettings,
IServiceProvider _serviceProvider) : ObservableObject IServiceProvider _serviceProvider) : ObservableObject
{ {
private readonly TopLevelCommandManager _tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
private readonly SettingsModel _settings = _serviceProvider.GetService<SettingsModel>()!; private readonly SettingsModel _settings = _serviceProvider.GetService<SettingsModel>()!;
public string DisplayName => _provider.DisplayName; public string DisplayName => _provider.DisplayName;
public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? "Built-in"; public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? "Built-in";
public string ExtensionSubtext => $"{ExtensionName}, {TopLevelCommands.Count} commands"; public string ExtensionSubtext => IsEnabled ? $"{ExtensionName}, {TopLevelCommands.Count} commands" : Resources.builtin_disabled_extension;
[MemberNotNullWhen(true, nameof(Extension))] [MemberNotNullWhen(true, nameof(Extension))]
public bool IsFromExtension => _provider.Extension != null; public bool IsFromExtension => _provider.Extension != null;
@@ -35,7 +37,29 @@ public partial class ProviderSettingsViewModel(
public bool IsEnabled public bool IsEnabled
{ {
get => _providerSettings.IsEnabled; get => _providerSettings.IsEnabled;
set => _providerSettings.IsEnabled = value; set
{
if (value != _providerSettings.IsEnabled)
{
_providerSettings.IsEnabled = value;
Save();
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(ExtensionSubtext));
}
if (value == true)
{
_provider.CommandsChanged -= Provider_CommandsChanged;
_provider.CommandsChanged += Provider_CommandsChanged;
}
}
}
private void Provider_CommandsChanged(CommandProviderWrapper sender, CommandPalette.Extensions.IItemsChangedEventArgs args)
{
OnPropertyChanged(nameof(ExtensionSubtext));
OnPropertyChanged(nameof(TopLevelCommands));
} }
public bool HasSettings => _provider.Settings != null && _provider.Settings.SettingsPage != null; public bool HasSettings => _provider.Settings != null && _provider.Settings.SettingsPage != null;
@@ -58,24 +82,12 @@ public partial class ProviderSettingsViewModel(
private List<TopLevelViewModel> BuildTopLevelViewModels() private List<TopLevelViewModel> BuildTopLevelViewModels()
{ {
var topLevelCommands = _tlcManager.TopLevelCommands;
var thisProvider = _provider; var thisProvider = _provider;
var providersCommands = thisProvider.TopLevelItems; var providersCommands = thisProvider.TopLevelItems;
List<TopLevelViewModel> results = [];
// Remember! This comes in on the UI thread! // Remember! This comes in on the UI thread!
// TODO: GH #426 return [.. providersCommands];
// Probably just do a background InitializeProperties
// Or better yet, merge TopLevelCommandWrapper and TopLevelViewModel
foreach (var command in providersCommands)
{
var match = topLevelCommands.Where(tlc => tlc.Model.Unsafe == command).FirstOrDefault();
if (match != null)
{
results.Add(new(match, _settings, _serviceProvider));
}
}
return results;
} }
private void Save() => SettingsModel.SaveSettings(_settings);
} }

View File

@@ -50,6 +50,23 @@ public partial class SettingsModel : ObservableObject
FilePath = SettingsJsonPath(); FilePath = SettingsJsonPath();
} }
public ProviderSettings GetProviderSettings(CommandProviderWrapper provider)
{
ProviderSettings? settings;
if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings))
{
settings = new ProviderSettings(provider);
settings.Connect(provider);
ProviderSettings[provider.ProviderId] = settings;
}
else
{
settings.Connect(provider);
}
return settings;
}
public static SettingsModel LoadSettings() public static SettingsModel LoadSettings()
{ {
if (string.IsNullOrEmpty(FilePath)) if (string.IsNullOrEmpty(FilePath))

View File

@@ -95,18 +95,7 @@ public partial class SettingsViewModel
foreach (var item in activeProviders) foreach (var item in activeProviders)
{ {
if (!allProviderSettings.TryGetValue(item.ProviderId, out var value)) var providerSettings = settings.GetProviderSettings(item);
{
allProviderSettings[item.ProviderId] = new ProviderSettings(item);
}
else
{
value.Connect(item);
}
var providerSettings = allProviderSettings.TryGetValue(item.ProviderId, out var value2) ?
value2 :
new ProviderSettings(item);
var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _serviceProvider); var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _serviceProvider);
CommandProviders.Add(settingsModel); CommandProviders.Add(settingsModel);

View File

@@ -1,224 +0,0 @@
// 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 CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using WyHash;
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
{
private readonly IServiceProvider _serviceProvider;
private readonly string _commandProviderId;
public ExtensionObject<ICommandItem> Model { get; }
public bool IsFallback { get; private set; }
private readonly string _idFromModel = string.Empty;
private string _generatedId = string.Empty;
public string Id => string.IsNullOrEmpty(_idFromModel) ? _generatedId : _idFromModel;
private readonly TopLevelCommandWrapper _topLevelCommand;
public CommandAlias? Alias { get; private set; }
private HotkeySettings? _hotkey;
public HotkeySettings? Hotkey
{
get => _hotkey;
set
{
UpdateHotkey();
UpdateTags();
}
}
public CommandPaletteHost ExtensionHost { get => _topLevelCommand.ExtensionHost; }
public TopLevelCommandItemWrapper(
ExtensionObject<ICommandItem> commandItem,
bool isFallback,
CommandPaletteHost extensionHost,
string commandProviderId,
IServiceProvider serviceProvider)
: base(new TopLevelCommandWrapper(
commandItem.Unsafe?.Command ?? new NoOpCommand(),
extensionHost))
{
_serviceProvider = serviceProvider;
_topLevelCommand = (TopLevelCommandWrapper)this.Command!;
_commandProviderId = commandProviderId;
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();
_idFromModel = _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);
}
GenerateId();
UpdateAlias();
UpdateHotkey();
UpdateTags();
}
private void GenerateId()
{
// Use WyHash64 to generate stable ID hashes.
// manually seeding with 0, so that the hash is stable across launches
var result = WyHash64.ComputeHash64(_commandProviderId + Title + Subtitle, seed: 0);
_generatedId = $"{_commandProviderId}{result}";
}
public void UpdateAlias(CommandAlias? newAlias)
{
_serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, newAlias);
UpdateAlias();
UpdateTags();
}
private void UpdateAlias()
{
// Add tags for the alias, if we have one.
var aliases = _serviceProvider.GetService<AliasManager>();
if (aliases != null)
{
Alias = aliases.AliasFromId(Id);
}
}
private void UpdateHotkey()
{
var settings = _serviceProvider.GetService<SettingsModel>()!;
var hotkey = settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault();
if (hotkey != null)
{
_hotkey = hotkey.Hotkey;
}
}
private void UpdateTags()
{
var tags = new List<Tag>();
if (Hotkey != null)
{
tags.Add(new Tag() { Text = Hotkey.ToString() });
}
if (Alias != null)
{
tags.Add(new Tag() { Text = Alias.SearchPrefix });
}
this.Tags = tags.ToArray();
}
private void Model_PropChanged(object sender, IPropChangedEventArgs 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;
case nameof(MoreCommands):
this.MoreCommands = model.MoreCommands;
break;
case nameof(Command):
this.Command = model.Command;
break;
}
}
catch
{
}
}
public void TryUpdateFallbackText(string newQuery)
{
if (!IsFallback)
{
return;
}
_ = Task.Run(() =>
{
try
{
var model = Model.Unsafe;
if (model is IFallbackCommandItem fallback)
{
var wasEmpty = string.IsNullOrEmpty(Title);
fallback.FallbackHandler.UpdateQuery(newQuery);
var isEmpty = string.IsNullOrEmpty(Title);
if (wasEmpty != isEmpty)
{
WeakReferenceMessenger.Default.Send<UpdateFallbackItemsMessage>();
}
}
}
catch (Exception)
{
}
});
}
}

View File

@@ -16,7 +16,8 @@ using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject, public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage> IRecipient<ReloadCommandsMessage>,
IPageContext
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly TaskScheduler _taskScheduler; private readonly TaskScheduler _taskScheduler;
@@ -24,6 +25,8 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = []; private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = []; private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
TaskScheduler IPageContext.Scheduler => _taskScheduler;
public TopLevelCommandManager(IServiceProvider serviceProvider) public TopLevelCommandManager(IServiceProvider serviceProvider)
{ {
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
@@ -31,7 +34,7 @@ public partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this); WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
} }
public ObservableCollection<TopLevelCommandItemWrapper> TopLevelCommands { get; set; } = []; public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
[ObservableProperty] [ObservableProperty]
public partial bool IsLoading { get; private set; } = true; public partial bool IsLoading { get; private set; } = true;
@@ -58,35 +61,43 @@ public partial class TopLevelCommandManager : ObservableObject,
// May be called from a background thread // May be called from a background thread
private async Task LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider) private async Task LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{ {
await commandProvider.LoadTopLevelCommands(); WeakReference<IPageContext> weakSelf = new(this);
await commandProvider.LoadTopLevelCommands(_serviceProvider, weakSelf);
var settings = _serviceProvider.GetService<SettingsModel>()!;
var makeAndAdd = (ICommandItem? i, bool fallback) => var makeAndAdd = (ICommandItem? i, bool fallback) =>
{ {
TopLevelCommandItemWrapper wrapper = new( var commandItemViewModel = new CommandItemViewModel(new(i), weakSelf);
new(i), fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, _serviceProvider); var topLevelViewModel = new TopLevelViewModel(commandItemViewModel, fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, settings, _serviceProvider);
lock (TopLevelCommands) lock (TopLevelCommands)
{ {
TopLevelCommands.Add(wrapper); TopLevelCommands.Add(topLevelViewModel);
} }
}; };
await Task.Factory.StartNew( await Task.Factory.StartNew(
() => () =>
{ {
foreach (var i in commandProvider.TopLevelItems) lock (TopLevelCommands)
{ {
makeAndAdd(i, false); foreach (var item in commandProvider.TopLevelItems)
} {
TopLevelCommands.Add(item);
}
foreach (var i in commandProvider.FallbackItems) foreach (var item in commandProvider.FallbackItems)
{ {
makeAndAdd(i, true); TopLevelCommands.Add(item);
}
} }
}, },
CancellationToken.None, CancellationToken.None,
TaskCreationOptions.None, TaskCreationOptions.None,
_taskScheduler); _taskScheduler);
commandProvider.CommandsChanged -= CommandProvider_CommandsChanged;
commandProvider.CommandsChanged += CommandProvider_CommandsChanged; commandProvider.CommandsChanged += CommandProvider_CommandsChanged;
} }
@@ -108,8 +119,8 @@ public partial class TopLevelCommandManager : ObservableObject,
{ {
// Work on a clone of the list, so that we can just do one atomic // Work on a clone of the list, so that we can just do one atomic
// update to the actual observable list at the end // update to the actual observable list at the end
List<TopLevelCommandItemWrapper> clone = [.. TopLevelCommands]; List<TopLevelViewModel> clone = [.. TopLevelCommands];
List<TopLevelCommandItemWrapper> newItems = []; List<TopLevelViewModel> newItems = [];
var startIndex = -1; var startIndex = -1;
var firstCommand = sender.TopLevelItems[0]; var firstCommand = sender.TopLevelItems[0];
var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length; var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length;
@@ -122,7 +133,8 @@ public partial class TopLevelCommandManager : ObservableObject,
var wrapper = clone[i]; var wrapper = clone[i];
try try
{ {
var thisCommand = wrapper.Model.Unsafe; // TODO! this can be safer, we're not directly exposing ICommandItem's out of CPW anymore
var thisCommand = wrapper.ItemViewModel.Model.Unsafe;
if (thisCommand != null) if (thisCommand != null)
{ {
var isTheSame = thisCommand == firstCommand; var isTheSame = thisCommand == firstCommand;
@@ -138,16 +150,21 @@ public partial class TopLevelCommandManager : ObservableObject,
} }
} }
WeakReference<IPageContext> weakSelf = new(this);
// Fetch the new items // Fetch the new items
await sender.LoadTopLevelCommands(); await sender.LoadTopLevelCommands(_serviceProvider, weakSelf);
var settings = _serviceProvider.GetService<SettingsModel>()!;
foreach (var i in sender.TopLevelItems) foreach (var i in sender.TopLevelItems)
{ {
newItems.Add(new(new(i), false, sender.ExtensionHost, sender.ProviderId, _serviceProvider)); newItems.Add(i);
} }
foreach (var i in sender.FallbackItems) foreach (var i in sender.FallbackItems)
{ {
newItems.Add(new(new(i), true, sender.ExtensionHost, sender.ProviderId, _serviceProvider)); newItems.Add(i);
} }
// Slice out the old commands // Slice out the old commands
@@ -253,7 +270,7 @@ public partial class TopLevelCommandManager : ObservableObject,
async () => async () =>
{ {
// Then find all the top-level commands that belonged to that extension // Then find all the top-level commands that belonged to that extension
List<TopLevelCommandItemWrapper> commandsToRemove = []; List<TopLevelViewModel> commandsToRemove = [];
lock (TopLevelCommands) lock (TopLevelCommands)
{ {
foreach (var extension in extensions) foreach (var extension in extensions)
@@ -292,7 +309,7 @@ public partial class TopLevelCommandManager : ObservableObject,
}); });
} }
public TopLevelCommandItemWrapper? LookupCommand(string id) public TopLevelViewModel? LookupCommand(string id)
{ {
lock (TopLevelCommands) lock (TopLevelCommands)
{ {
@@ -310,4 +327,10 @@ public partial class TopLevelCommandManager : ObservableObject,
public void Receive(ReloadCommandsMessage message) => public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false); ReloadAllCommandsAsync().ConfigureAwait(false);
void IPageContext.ShowException(Exception ex, string? extensionHint)
{
var errorMessage = $"A bug occurred in {$"the \"{extensionHint}\"" ?? "an unknown's"} extension's code:\n{ex.Message}\n{ex.Source}\n{ex.StackTrace}\n\n";
CommandPaletteHost.Instance.Log(errorMessage);
}
} }

View File

@@ -1,74 +0,0 @@
// 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.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandWrapper : ICommand
{
private readonly ExtensionObject<ICommand> _command;
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
public string Name { get; private set; } = string.Empty;
public string Id { get; private set; } = string.Empty;
public IIconInfo Icon { get; private set; } = new IconInfo(null);
public ICommand Command => _command.Unsafe!;
public CommandPaletteHost ExtensionHost { get; }
public TopLevelCommandWrapper(ICommand command, CommandPaletteHost extensionHost)
{
_command = new(command);
ExtensionHost = extensionHost;
}
public void UnsafeInitializeProperties()
{
var model = _command.Unsafe!;
Name = model.Name;
Id = model.Id;
Icon = model.Icon;
model.PropChanged += Model_PropChanged;
model.PropChanged += this.PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
var propertyName = args.PropertyName;
var model = _command.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Name):
this.Name = model.Name;
break;
case nameof(Icon):
var listIcon = model.Icon;
Icon = model.Icon;
break;
}
PropChanged?.Invoke(this, args);
}
catch
{
}
}
}

View File

@@ -2,95 +2,267 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation;
using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelViewModel : ObservableObject public sealed partial class TopLevelViewModel : ObservableObject, IListItem
{ {
private readonly SettingsModel _settings; private readonly SettingsModel _settings;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
// TopLevelCommandItemWrapper is a ListItem, but it's in-memory for the app already. private readonly string _commandProviderId;
// We construct it either from data that we pulled from the cache, or from the
// extension, but the data in it is all in our process now.
private readonly TopLevelCommandItemWrapper _item;
public IconInfoViewModel Icon { get; private set; } private string IdFromModel => _commandItemViewModel.Command.Id;
public string Title => _item.Title; private string _generatedId = string.Empty;
public string Subtitle => _item.Subtitle; private HotkeySettings? _hotkey;
private CommandAlias? Alias { get; set; }
public bool IsFallback { get; private set; }
[ObservableProperty]
public partial ObservableCollection<Tag> Tags { get; set; } = [];
public string Id => string.IsNullOrEmpty(IdFromModel) ? _generatedId : IdFromModel;
public CommandPaletteHost ExtensionHost { get; private set; }
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
public string Subtitle => _commandItemViewModel.Subtitle;
public IIconInfo Icon => _commandItemViewModel.Icon;
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands.Select(i => i.Model.Unsafe).ToArray();
////// IListItem
ITag[] IListItem.Tags => Tags.ToArray();
IDetails? IListItem.Details => null;
string IListItem.Section => string.Empty;
string IListItem.TextToSuggest => string.Empty;
////// INotifyPropChanged
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
public HotkeySettings? Hotkey public HotkeySettings? Hotkey
{ {
get => _item.Hotkey; get => _hotkey;
set set
{ {
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(_item.Id, value); _serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(Id, value);
_item.Hotkey = value; UpdateHotkey();
UpdateTags();
Save(); Save();
} }
} }
private string _aliasText; public bool HasAlias => !string.IsNullOrEmpty(AliasText);
public string AliasText public string AliasText
{ {
get => _aliasText; get => Alias?.Alias ?? string.Empty;
set set
{ {
if (SetProperty(ref _aliasText, value)) if (string.IsNullOrEmpty(value))
{ {
UpdateAlias(); Alias = null;
} }
else
{
if (Alias is CommandAlias a)
{
a.Alias = value;
}
else
{
Alias = new CommandAlias(value, Id);
}
}
HandleChangeAlias();
} }
} }
private bool _isDirectAlias;
public bool IsDirectAlias public bool IsDirectAlias
{ {
get => _isDirectAlias; get => Alias?.IsDirect ?? false;
set set
{ {
if (SetProperty(ref _isDirectAlias, value)) if (Alias is CommandAlias a)
{ {
UpdateAlias(); a.IsDirect = value;
} }
HandleChangeAlias();
} }
} }
public TopLevelViewModel(TopLevelCommandItemWrapper item, SettingsModel settings, IServiceProvider serviceProvider) public TopLevelViewModel(
CommandItemViewModel item,
bool isFallback,
CommandPaletteHost extensionHost,
string commandProviderId,
SettingsModel settings,
IServiceProvider serviceProvider)
{ {
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_settings = settings; _settings = settings;
_commandProviderId = commandProviderId;
_commandItemViewModel = item;
_item = item; IsFallback = isFallback;
Icon = new(item.Icon ?? item.Command?.Icon); ExtensionHost = extensionHost;
Icon.InitializeProperties();
var aliases = _serviceProvider.GetService<AliasManager>()!; item.PropertyChanged += Item_PropertyChanged;
_isDirectAlias = _item.Alias?.IsDirect ?? false;
_aliasText = _item.Alias?.Alias ?? string.Empty; // UpdateAlias();
// UpdateHotkey();
// UpdateTags();
}
private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!string.IsNullOrEmpty(e.PropertyName))
{
PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName));
if (e.PropertyName == "IsInitialized")
{
GenerateId();
UpdateAlias();
UpdateHotkey();
UpdateTags();
}
}
} }
private void Save() => SettingsModel.SaveSettings(_settings); private void Save() => SettingsModel.SaveSettings(_settings);
private void UpdateAlias() private void HandleChangeAlias()
{ {
if (string.IsNullOrWhiteSpace(_aliasText)) SetAlias(Alias);
{
_item.UpdateAlias(null);
}
else
{
var newAlias = new CommandAlias(_aliasText, _item.Id, _isDirectAlias);
_item.UpdateAlias(newAlias);
}
Save(); Save();
} }
public void SetAlias(CommandAlias? newAlias)
{
_serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, newAlias);
UpdateAlias();
UpdateTags();
}
private void UpdateAlias()
{
// Add tags for the alias, if we have one.
var aliases = _serviceProvider.GetService<AliasManager>();
if (aliases != null)
{
Alias = aliases.AliasFromId(Id);
}
}
private void UpdateHotkey()
{
var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault();
if (hotkey != null)
{
_hotkey = hotkey.Hotkey;
}
}
private void UpdateTags()
{
List<Tag> tags = new();
if (Hotkey != null)
{
tags.Add(new Tag() { Text = Hotkey.ToString() });
}
if (Alias != null)
{
tags.Add(new Tag() { Text = Alias.SearchPrefix });
}
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Tags)));
DoOnUiThread(
() =>
{
ListHelpers.InPlaceUpdateList(Tags, tags);
});
}
private void GenerateId()
{
// Use WyHash64 to generate stable ID hashes.
// manually seeding with 0, so that the hash is stable across launches
var result = WyHash64.ComputeHash64(_commandProviderId + Title + Subtitle, seed: 0);
_generatedId = $"{_commandProviderId}{result}";
}
private void DoOnUiThread(Action action)
{
if (_commandItemViewModel.PageContext.TryGetTarget(out var pageContext))
{
Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
pageContext.Scheduler);
}
}
public void TryUpdateFallbackText(string newQuery)
{
if (!IsFallback)
{
return;
}
_ = Task.Run(() =>
{
try
{
var model = _commandItemViewModel.Model.Unsafe;
if (model is IFallbackCommandItem fallback)
{
var wasEmpty = string.IsNullOrEmpty(Title);
fallback.FallbackHandler.UpdateQuery(newQuery);
var isEmpty = string.IsNullOrEmpty(Title);
if (wasEmpty != isEmpty)
{
WeakReferenceMessenger.Default.Send<UpdateFallbackItemsMessage>();
}
}
}
catch (Exception)
{
}
});
}
} }

View File

@@ -2,13 +2,11 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using CommunityToolkit.WinUI; using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.Deferred; using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Controls; namespace Microsoft.CmdPal.UI.Controls;
@@ -39,7 +37,14 @@ public partial class ContentIcon : FontIcon
{ {
if (this.FindDescendants().OfType<Grid>().FirstOrDefault() is Grid grid && Content is not null) if (this.FindDescendants().OfType<Grid>().FirstOrDefault() is Grid grid && Content is not null)
{ {
grid.Children.Add(Content); try
{
grid.Children.Add(Content);
}
catch (COMException ex)
{
Logger.LogError(ex.ToString());
}
} }
} }
} }

View File

@@ -123,18 +123,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// Or the command may be a stub. Future us problem. // Or the command may be a stub. Future us problem.
try try
{ {
var host = ViewModel.CurrentPage?.ExtensionHost ?? CommandPaletteHost.Instance; var pageHost = ViewModel.CurrentPage?.ExtensionHost;
var messageHost = message.ExtensionHost;
if (command is TopLevelCommandWrapper wrapper) // 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.
var host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
if (extension != null)
{ {
var tlc = wrapper; if (messageHost != null)
command = wrapper.Command;
host = tlc.ExtensionHost != null ? tlc.ExtensionHost! : host;
extension = tlc.ExtensionHost?.Extension;
if (extension != null)
{ {
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}"); Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
} }
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
} }
ViewModel.SetActiveExtension(extension); ViewModel.SetActiveExtension(extension);
@@ -476,8 +483,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
var topLevelCommand = tlcManager.LookupCommand(commandId); var topLevelCommand = tlcManager.LookupCommand(commandId);
if (topLevelCommand != null) if (topLevelCommand != null)
{ {
var command = topLevelCommand.Command; var command = topLevelCommand.CommandViewModel.Model.Unsafe;
var isPage = command is TopLevelCommandWrapper wrapper && wrapper.Command is not IInvokableCommand; var isPage = command is not IInvokableCommand;
// If the bound command is an invokable command, then // If the bound command is an invokable command, then
// we don't want to open the window at all - we want to // we don't want to open the window at all - we want to

View File

@@ -48,75 +48,107 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}"> Spacing="{StaticResource SettingsCardSpacing}">
<TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> <controls:SettingsCard x:Uid="ExtensionEnableCard" HeaderIcon="{ui:FontIcon Glyph=&#xEA86;}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
<ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="viewmodels:TopLevelViewModel">
<controls:SettingsExpander
DataContext="{x:Bind}"
Description="{x:Bind Subtitle, Mode=OneWay}"
Header="{x:Bind Title, Mode=OneWay}">
<controls:SettingsExpander.HeaderIcon>
<cpcontrols:ContentIcon>
<cpcontrols:ContentIcon.Content>
<cpcontrols:IconBox
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsExpander.HeaderIcon>
<!-- Content goes here -->
<controls:SettingsExpander.Items>
<controls:SettingsCard x:Uid="Settings_ExtensionPage_GlobalHotkey_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}">
<cpcontrols:ShortcutControl HotkeySettings="{x:Bind Hotkey, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard x:Uid="Settings_ExtensionPage_Alias_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE8AC;}">
<StackPanel Orientation="Vertical">
<TextBox Text="{x:Bind AliasText, Mode=TwoWay}" />
<ToggleSwitch
IsEnabled="{x:Bind AliasText, Converter={StaticResource StringEmptyToBoolConverter}, Mode=OneWay}"
IsOn="{x:Bind IsDirectAlias, Mode=TwoWay}"
OffContent="Indirect"
OnContent="Direct" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<TextBlock
x:Uid="ExtensionSettingsHeader"
Style="{StaticResource SettingsSectionHeaderTextBlockStyle}"
Visibility="{x:Bind ViewModel.HasSettings}" />
<Frame x:Name="SettingsFrame" Visibility="{x:Bind ViewModel.HasSettings}">
<cmdpalUI:ContentPage ViewModel="{x:Bind ViewModel.SettingsPage, Mode=OneWay}" />
</Frame>
<TextBlock x:Uid="ExtensionAboutHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<controls:SettingsCard
Description="{x:Bind ViewModel.Extension.Publisher, Mode=OneWay}"
Header="{x:Bind ViewModel.Extension.PackageDisplayName, Mode=OneWay}"
Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay}">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Text="{x:Bind ViewModel.ExtensionVersion}" />
</controls:SettingsCard> </controls:SettingsCard>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<controls:Case Value="True">
<StackPanel Orientation="Vertical">
<TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="viewmodels:TopLevelViewModel">
<controls:SettingsExpander
DataContext="{x:Bind}"
Description="{x:Bind Subtitle, Mode=OneWay}"
Header="{x:Bind Title, Mode=OneWay}">
<controls:SettingsExpander.HeaderIcon>
<cpcontrols:ContentIcon>
<cpcontrols:ContentIcon.Content>
<cpcontrols:IconBox
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsExpander.HeaderIcon>
<!-- Content goes here -->
<controls:SettingsExpander.Items>
<controls:SettingsCard x:Uid="Settings_ExtensionPage_GlobalHotkey_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}">
<cpcontrols:ShortcutControl HotkeySettings="{x:Bind Hotkey, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard x:Uid="Settings_ExtensionPage_Alias_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE8AC;}">
<StackPanel Orientation="Vertical">
<TextBox Text="{x:Bind AliasText, Mode=TwoWay}" />
<ToggleSwitch
IsEnabled="{x:Bind AliasText, Converter={StaticResource StringEmptyToBoolConverter}, Mode=OneWay}"
IsOn="{x:Bind IsDirectAlias, Mode=TwoWay}"
OffContent="Indirect"
OnContent="Direct" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<TextBlock
x:Uid="ExtensionSettingsHeader"
Style="{StaticResource SettingsSectionHeaderTextBlockStyle}"
Visibility="{x:Bind ViewModel.HasSettings}" />
<Frame x:Name="SettingsFrame" Visibility="{x:Bind ViewModel.HasSettings}">
<cmdpalUI:ContentPage ViewModel="{x:Bind ViewModel.SettingsPage, Mode=OneWay}" />
</Frame>
<TextBlock
x:Uid="ExtensionAboutHeader"
Style="{StaticResource SettingsSectionHeaderTextBlockStyle}"
Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay}" />
<controls:SettingsCard
Description="{x:Bind ViewModel.Extension.Publisher, Mode=OneWay}"
Header="{x:Bind ViewModel.Extension.PackageDisplayName, Mode=OneWay}"
Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay}">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Text="{x:Bind ViewModel.ExtensionVersion}" />
</controls:SettingsCard>
</StackPanel>
</controls:Case>
<controls:Case Value="False">
<StackPanel>
<TextBlock x:Uid="ExtensionDisabledHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<TextBlock x:Uid="ExtensionDisabledDetails" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
<TextBlock
x:Uid="ExtensionAboutHeader"
Style="{StaticResource SettingsSectionHeaderTextBlockStyle}"
Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" />
<controls:SettingsCard x:Uid="Settings_ExtensionPage_Builtin_SettingsCard" Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> <controls:SettingsCard x:Uid="Settings_ExtensionPage_Builtin_SettingsCard" Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" />
</StackPanel> </StackPanel>
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>

View File

@@ -48,8 +48,7 @@
</cpcontrols:ContentIcon> </cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon> </controls:SettingsCard.HeaderIcon>
<!-- In the near future: add a toggle to actually expose if a extension is enabled --> <ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" />
<!-- <ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" /> -->
</controls:SettingsCard> </controls:SettingsCard>
</DataTemplate> </DataTemplate>

View File

@@ -81,8 +81,8 @@
<controls:SettingsExpander.Items> <controls:SettingsExpander.Items>
<controls:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left"> <controls:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
<StackPanel Margin="-12,0,0,0" Orientation="Vertical"> <StackPanel Margin="-12,0,0,0" Orientation="Vertical">
<HyperlinkButton x:Uid="Settings_GeneralPage_About_GithubLink_Hyperlink" NavigateUri="https://github.com/zadjii-msft/PowerToys" /> <HyperlinkButton x:Uid="Settings_GeneralPage_About_GithubLink_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310837" />
<HyperlinkButton x:Uid="Settings_GeneralPage_About_SDKDocs_Hyperlink" NavigateUri="https://github.com/zadjii-msft/PowerToys/blob/main/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md" /> <HyperlinkButton x:Uid="Settings_GeneralPage_About_SDKDocs_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310639" />
</StackPanel> </StackPanel>
</controls:SettingsCard> </controls:SettingsCard>
</controls:SettingsExpander.Items> </controls:SettingsExpander.Items>
@@ -90,7 +90,7 @@
<HyperlinkButton <HyperlinkButton
x:Uid="Settings_GeneralPage_SendFeedback_Hyperlink" x:Uid="Settings_GeneralPage_SendFeedback_Hyperlink"
Margin="0,8,0,0" Margin="0,8,0,0"
NavigateUri="https://github.com/zadjii-msft/PowerToys/issues/new" /> NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310638" />
</StackPanel> </StackPanel>
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>

View File

@@ -42,7 +42,31 @@
<NavigationView.Resources> <NavigationView.Resources>
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" /> <SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" /> <SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
<Thickness x:Key="NavigationViewHeaderMargin">15,0,0,0</Thickness>
</NavigationView.Resources> </NavigationView.Resources>
<NavigationView.Header>
<BreadcrumbBar
x:Name="NavigationBreadcrumbBar"
MaxWidth="1000"
ItemClicked="NavigationBreadcrumbBar_ItemClicked"
ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}">
<BreadcrumbBar.ItemTemplate>
<DataTemplate x:DataType="local:Crumb">
<TextBlock Text="{x:Bind Label, Mode=OneWay}" />
</DataTemplate>
</BreadcrumbBar.ItemTemplate>
<BreadcrumbBar.Resources>
<ResourceDictionary>
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">28</x:Double>
<Thickness x:Key="BreadcrumbBarChevronPadding">7,4,8,0</Thickness>
<FontWeight x:Key="BreadcrumbBarItemFontWeight">SemiBold</FontWeight>
<x:Double x:Key="BreadcrumbBarChevronFontSize">16</x:Double>
</ResourceDictionary>
</BreadcrumbBar.Resources>
</BreadcrumbBar>
</NavigationView.Header>
<NavigationView.MenuItems> <NavigationView.MenuItems>
<NavigationViewItem <NavigationViewItem
x:Uid="Settings_GeneralPage_NavigationViewItem_General" x:Uid="Settings_GeneralPage_NavigationViewItem_General"
@@ -60,17 +84,6 @@
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<BreadcrumbBar
x:Name="NavigationBreadcrumbBar"
MaxWidth="1000"
ItemClicked="NavigationBreadcrumbBar_ItemClicked"
ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}">
<BreadcrumbBar.ItemTemplate>
<DataTemplate x:DataType="local:Crumb">
<TextBlock Style="{StaticResource TitleTextBlockStyle}" Text="{x:Bind Label, Mode=OneWay}" />
</DataTemplate>
</BreadcrumbBar.ItemTemplate>
</BreadcrumbBar>
<Frame x:Name="NavFrame" Grid.Row="1" /> <Frame x:Name="NavFrame" Grid.Row="1" />
</Grid> </Grid>
</NavigationView> </NavigationView>

View File

@@ -4,8 +4,8 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Pages;
using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
@@ -15,7 +15,8 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI.Settings; namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class SettingsWindow : Window, public sealed partial class SettingsWindow : Window,
IRecipient<NavigateToExtensionSettingsMessage> IRecipient<NavigateToExtensionSettingsMessage>,
IRecipient<QuitMessage>
{ {
public ObservableCollection<Crumb> BreadCrumbs { get; } = []; public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
@@ -27,7 +28,9 @@ public sealed partial class SettingsWindow : Window,
this.AppWindow.Title = RS_.GetString("SettingsWindowTitle"); this.AppWindow.Title = RS_.GetString("SettingsWindowTitle");
this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
PositionCentered(); PositionCentered();
WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this); WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
} }
private void NavView_Loaded(object sender, RoutedEventArgs e) private void NavView_Loaded(object sender, RoutedEventArgs e)
@@ -101,6 +104,12 @@ public sealed partial class SettingsWindow : Window,
{ {
WeakReferenceMessenger.Default.Send<WindowActivatedEventArgs>(args); WeakReferenceMessenger.Default.Send<WindowActivatedEventArgs>(args);
} }
public void Receive(QuitMessage message)
{
// This might come in on a background thread
DispatcherQueue.TryEnqueue(() => Close());
}
} }
public readonly struct Crumb public readonly struct Crumb

View File

@@ -241,6 +241,26 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Commands</value> <value>Commands</value>
<comment>A section header for information about the app</comment> <comment>A section header for information about the app</comment>
</data> </data>
<data name="ExtensionDisabledHeader.Text" xml:space="preserve">
<value>This extension is disabled</value>
<comment>A header to inform the user that an extension is not currently active</comment>
</data>
<data name="ExtensionDisabledDetails.Text" xml:space="preserve">
<value>Enable this extension to view commands and settings</value>
<comment>Additional details for when an extension is disabled. Displayed under ExtensionDisabledHeader.Text</comment>
</data>
<data name="ExtensionDisabledText" xml:space="preserve">
<value>Disabled</value>
<comment>Displayed when an extension is disabled</comment>
</data>
<data name="ExtensionEnableCard.Header" xml:space="preserve">
<value>Enable this extension</value>
<comment>Displayed on a toggle controlling the extension's enabled / disabled state</comment>
</data>
<data name="ExtensionEnableCard.Description" xml:space="preserve">
<value>Load commands and settings from this extension</value>
<comment>Displayed on a toggle controlling the extension's enabled / disabled state</comment>
</data>
<data name="SettingsWindowTitle" xml:space="preserve"> <data name="SettingsWindowTitle" xml:space="preserve">
<value>Command Palette Settings</value> <value>Command Palette Settings</value>
<comment>The title of the settings window for the app</comment> <comment>The title of the settings window for the app</comment>

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -8,5 +8,5 @@ namespace Microsoft.CmdPal.Ext.Shell;
internal sealed class Icons internal sealed class Icons
{ {
internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run@2x.svg"); internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run_V2_2x.svg");
} }