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>
</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>
</Content>
</ItemGroup>

View File

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

View File

@@ -115,7 +115,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
}
/// <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>
public static string cmd_run_as_administrator {
get {
@@ -124,7 +124,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
}
/// <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>
public static string cmd_run_as_user {
get {

View File

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

View File

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

View File

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

View File

@@ -146,6 +146,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Subtitle));
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));
Initialized |= InitializedState.Initialized;

View File

@@ -6,6 +6,8 @@ using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -20,9 +22,9 @@ public sealed class CommandProviderWrapper
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;
@@ -38,7 +40,13 @@ public sealed class CommandProviderWrapper
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)
{
@@ -105,13 +113,25 @@ public sealed class CommandProviderWrapper
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)
{
return;
}
var settings = serviceProvider.GetService<SettingsModel>()!;
if (!GetProviderSettings(settings).IsEnabled)
{
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
@@ -119,7 +139,7 @@ public sealed class CommandProviderWrapper
{
var model = _commandProvider.Unsafe!;
var t = new Task<ICommandItem[]>(model.TopLevelCommands);
Task<ICommandItem[]> t = new(model.TopLevelCommands);
t.Start();
commands = await t.ConfigureAwait(false);
@@ -134,6 +154,8 @@ public sealed class CommandProviderWrapper
Settings = new(model.Settings, this, _taskScheduler);
Settings.InitializeProperties();
InitializeCommands(commands, fallbacks, serviceProvider, pageContext);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
catch (Exception e)
@@ -142,15 +164,33 @@ public sealed class CommandProviderWrapper
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
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)
{
TopLevelItems = commands;
TopLevelItems = commands
.Select(c => makeAndAdd(c, false))
.ToArray();
}
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;
}
Id = model.Id ?? string.Empty;
Name = model.Name ?? string.Empty;
IsFastInitialized = true;
}

View File

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

View File

@@ -9,7 +9,7 @@ using Windows.Storage.Streams;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class IconDataViewModel : ObservableObject
public partial class IconDataViewModel : ObservableObject, IIconData
{
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
public ExtensionObject<IRandomAccessStreamReference> Data { get; private set; } = new(null);
IRandomAccessStreamReference? IIconData.Data => Data.Unsafe;
public IconDataViewModel(IIconData? icon)
{
_model = new(icon);

View File

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

View File

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

View File

@@ -106,14 +106,14 @@ public class ExtensionWrapper : IExtensionWrapper
{
Logger.LogDebug($"Starting {ExtensionDisplayName} ({ExtensionClassId})");
nint extensionPtr = nint.Zero;
var extensionPtr = nint.Zero;
try
{
// -2147024809: E_INVALIDARG
// -2147467262: E_NOINTERFACE
// -2147024893: E_PATH_NOT_FOUND
Guid 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 guid = typeof(IExtension).GUID;
var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out var extensionObj);
if (hr.Value == -2147024893)
{
@@ -179,7 +179,7 @@ public class ExtensionWrapper : IExtensionWrapper
{
await StartExtensionAsync();
object? supportedProviders = GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]);
var supportedProviders = GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]);
if (supportedProviders is IEnumerable<T> 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>
/// Looks up a localized string similar to Built-in commands.
/// </summary>

View File

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

View File

@@ -10,23 +10,14 @@ public class ProviderSettings
{
public bool IsEnabled { get; set; } = true;
[JsonIgnore]
public string PackageFamilyName { get; set; } = string.Empty;
[JsonIgnore]
public string Id { get; set; } = string.Empty;
[JsonIgnore]
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]
public string ProviderId => $"{PackageFamilyName}/{Id}";
public string ProviderId { get; private set; } = string.Empty;
[JsonIgnore]
public bool IsBuiltin => string.IsNullOrEmpty(PackageFamilyName);
public bool IsBuiltin { get; private set; }
public ProviderSettings(CommandProviderWrapper wrapper)
{
@@ -41,10 +32,12 @@ public class ProviderSettings
public void Connect(CommandProviderWrapper wrapper)
{
PackageFamilyName = wrapper.Extension?.PackageFamilyName ?? string.Empty;
Id = wrapper.DisplayName;
ProviderId = wrapper.ProviderId;
IsBuiltin = wrapper.Extension == null;
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!");
}

View File

@@ -4,7 +4,10 @@
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -14,14 +17,13 @@ public partial class ProviderSettingsViewModel(
ProviderSettings _providerSettings,
IServiceProvider _serviceProvider) : ObservableObject
{
private readonly TopLevelCommandManager _tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
private readonly SettingsModel _settings = _serviceProvider.GetService<SettingsModel>()!;
public string DisplayName => _provider.DisplayName;
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))]
public bool IsFromExtension => _provider.Extension != null;
@@ -35,7 +37,29 @@ public partial class ProviderSettingsViewModel(
public bool 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;
@@ -58,24 +82,12 @@ public partial class ProviderSettingsViewModel(
private List<TopLevelViewModel> BuildTopLevelViewModels()
{
var topLevelCommands = _tlcManager.TopLevelCommands;
var thisProvider = _provider;
var providersCommands = thisProvider.TopLevelItems;
List<TopLevelViewModel> results = [];
// Remember! This comes in on the UI thread!
// TODO: GH #426
// 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 [.. providersCommands];
}
return results;
}
private void Save() => SettingsModel.SaveSettings(_settings);
}

View File

@@ -50,6 +50,23 @@ public partial class SettingsModel : ObservableObject
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()
{
if (string.IsNullOrEmpty(FilePath))

View File

@@ -95,18 +95,7 @@ public partial class SettingsViewModel
foreach (var item in activeProviders)
{
if (!allProviderSettings.TryGetValue(item.ProviderId, out var value))
{
allProviderSettings[item.ProviderId] = new ProviderSettings(item);
}
else
{
value.Connect(item);
}
var providerSettings = allProviderSettings.TryGetValue(item.ProviderId, out var value2) ?
value2 :
new ProviderSettings(item);
var providerSettings = settings.GetProviderSettings(item);
var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _serviceProvider);
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;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>
IRecipient<ReloadCommandsMessage>,
IPageContext
{
private readonly IServiceProvider _serviceProvider;
private readonly TaskScheduler _taskScheduler;
@@ -24,6 +25,8 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
TaskScheduler IPageContext.Scheduler => _taskScheduler;
public TopLevelCommandManager(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
@@ -31,7 +34,7 @@ public partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
}
public ObservableCollection<TopLevelCommandItemWrapper> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
@@ -58,35 +61,43 @@ public partial class TopLevelCommandManager : ObservableObject,
// May be called from a background thread
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) =>
{
TopLevelCommandItemWrapper wrapper = new(
new(i), fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, _serviceProvider);
var commandItemViewModel = new CommandItemViewModel(new(i), weakSelf);
var topLevelViewModel = new TopLevelViewModel(commandItemViewModel, fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, settings, _serviceProvider);
lock (TopLevelCommands)
{
TopLevelCommands.Add(wrapper);
TopLevelCommands.Add(topLevelViewModel);
}
};
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,
TaskCreationOptions.None,
_taskScheduler);
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
// update to the actual observable list at the end
List<TopLevelCommandItemWrapper> clone = [.. TopLevelCommands];
List<TopLevelCommandItemWrapper> newItems = [];
List<TopLevelViewModel> clone = [.. TopLevelCommands];
List<TopLevelViewModel> newItems = [];
var startIndex = -1;
var firstCommand = sender.TopLevelItems[0];
var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length;
@@ -122,7 +133,8 @@ public partial class TopLevelCommandManager : ObservableObject,
var wrapper = clone[i];
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)
{
var isTheSame = thisCommand == firstCommand;
@@ -138,16 +150,21 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
WeakReference<IPageContext> weakSelf = new(this);
// Fetch the new items
await sender.LoadTopLevelCommands();
await sender.LoadTopLevelCommands(_serviceProvider, weakSelf);
var settings = _serviceProvider.GetService<SettingsModel>()!;
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)
{
newItems.Add(new(new(i), true, sender.ExtensionHost, sender.ProviderId, _serviceProvider));
newItems.Add(i);
}
// Slice out the old commands
@@ -253,7 +270,7 @@ public partial class TopLevelCommandManager : ObservableObject,
async () =>
{
// Then find all the top-level commands that belonged to that extension
List<TopLevelCommandItemWrapper> commandsToRemove = [];
List<TopLevelViewModel> commandsToRemove = [];
lock (TopLevelCommands)
{
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)
{
@@ -310,4 +327,10 @@ public partial class TopLevelCommandManager : ObservableObject,
public void Receive(ReloadCommandsMessage message) =>
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.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation;
using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelViewModel : ObservableObject
public sealed partial class TopLevelViewModel : ObservableObject, IListItem
{
private readonly SettingsModel _settings;
private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
// TopLevelCommandItemWrapper is a ListItem, but it's in-memory for the app already.
// 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;
private readonly string _commandProviderId;
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
{
get => _item.Hotkey;
get => _hotkey;
set
{
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(_item.Id, value);
_item.Hotkey = value;
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(Id, value);
UpdateHotkey();
UpdateTags();
Save();
}
}
private string _aliasText;
public bool HasAlias => !string.IsNullOrEmpty(AliasText);
public string AliasText
{
get => _aliasText;
get => Alias?.Alias ?? string.Empty;
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);
}
}
private bool _isDirectAlias;
HandleChangeAlias();
}
}
public bool IsDirectAlias
{
get => _isDirectAlias;
get => Alias?.IsDirect ?? false;
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;
_settings = settings;
_commandProviderId = commandProviderId;
_commandItemViewModel = item;
_item = item;
Icon = new(item.Icon ?? item.Command?.Icon);
Icon.InitializeProperties();
IsFallback = isFallback;
ExtensionHost = extensionHost;
var aliases = _serviceProvider.GetService<AliasManager>()!;
_isDirectAlias = _item.Alias?.IsDirect ?? false;
_aliasText = _item.Alias?.Alias ?? string.Empty;
item.PropertyChanged += Item_PropertyChanged;
// 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 UpdateAlias()
private void HandleChangeAlias()
{
if (string.IsNullOrWhiteSpace(_aliasText))
{
_item.UpdateAlias(null);
}
else
{
var newAlias = new CommandAlias(_aliasText, _item.Id, _isDirectAlias);
_item.UpdateAlias(newAlias);
}
SetAlias(Alias);
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.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.Deferred;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Dispatching;
using ManagedCommon;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Controls;
@@ -38,8 +36,15 @@ public partial class ContentIcon : FontIcon
private void IconBoxElement_Loaded(object sender, RoutedEventArgs e)
{
if (this.FindDescendants().OfType<Grid>().FirstOrDefault() is Grid grid && Content is not null)
{
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.
try
{
var host = ViewModel.CurrentPage?.ExtensionHost ?? CommandPaletteHost.Instance;
var pageHost = ViewModel.CurrentPage?.ExtensionHost;
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.
var host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
if (command is TopLevelCommandWrapper wrapper)
{
var tlc = wrapper;
command = wrapper.Command;
host = tlc.ExtensionHost != null ? tlc.ExtensionHost! : host;
extension = tlc.ExtensionHost?.Extension;
if (extension != null)
{
if (messageHost != null)
{
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
}
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
}
ViewModel.SetActiveExtension(extension);
@@ -476,8 +483,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
var topLevelCommand = tlcManager.LookupCommand(commandId);
if (topLevelCommand != null)
{
var command = topLevelCommand.Command;
var isPage = command is TopLevelCommandWrapper wrapper && wrapper.Command is not IInvokableCommand;
var command = topLevelCommand.CommandViewModel.Model.Unsafe;
var isPage = command is not IInvokableCommand;
// If the bound command is an invokable command, then
// we don't want to open the window at all - we want to

View File

@@ -48,6 +48,19 @@
HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}">
<controls:SettingsCard x:Uid="ExtensionEnableCard" HeaderIcon="{ui:FontIcon Glyph=&#xEA86;}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
</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}">
@@ -103,7 +116,10 @@
<cmdpalUI:ContentPage ViewModel="{x:Bind ViewModel.SettingsPage, Mode=OneWay}" />
</Frame>
<TextBlock x:Uid="ExtensionAboutHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<TextBlock
x:Uid="ExtensionAboutHeader"
Style="{StaticResource SettingsSectionHeaderTextBlockStyle}"
Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay}" />
<controls:SettingsCard
Description="{x:Bind ViewModel.Extension.Publisher, Mode=OneWay}"
@@ -114,9 +130,25 @@
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}}" />
</StackPanel>
</Grid>
</ScrollViewer>

View File

@@ -48,8 +48,7 @@
</cpcontrols:ContentIcon>
</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>
</DataTemplate>

View File

@@ -81,8 +81,8 @@
<controls:SettingsExpander.Items>
<controls:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
<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_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_GithubLink_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310837" />
<HyperlinkButton x:Uid="Settings_GeneralPage_About_SDKDocs_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310639" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
@@ -90,7 +90,7 @@
<HyperlinkButton
x:Uid="Settings_GeneralPage_SendFeedback_Hyperlink"
Margin="0,8,0,0"
NavigateUri="https://github.com/zadjii-msft/PowerToys/issues/new" />
NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310638" />
</StackPanel>
</Grid>
</ScrollViewer>

View File

@@ -42,7 +42,31 @@
<NavigationView.Resources>
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
<Thickness x:Key="NavigationViewHeaderMargin">15,0,0,0</Thickness>
</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>
<NavigationViewItem
x:Uid="Settings_GeneralPage_NavigationViewItem_General"
@@ -60,17 +84,6 @@
<RowDefinition Height="*" />
</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" />
</Grid>
</NavigationView>

View File

@@ -4,8 +4,8 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Pages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -15,7 +15,8 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class SettingsWindow : Window,
IRecipient<NavigateToExtensionSettingsMessage>
IRecipient<NavigateToExtensionSettingsMessage>,
IRecipient<QuitMessage>
{
public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
@@ -27,7 +28,9 @@ public sealed partial class SettingsWindow : Window,
this.AppWindow.Title = RS_.GetString("SettingsWindowTitle");
this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
PositionCentered();
WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
}
private void NavView_Loaded(object sender, RoutedEventArgs e)
@@ -101,6 +104,12 @@ public sealed partial class SettingsWindow : Window,
{
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

View File

@@ -241,6 +241,26 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Commands</value>
<comment>A section header for information about the app</comment>
</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">
<value>Command Palette Settings</value>
<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 static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run@2x.svg");
internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run_V2_2x.svg");
}