Add the Command Palette module (#37908)

Windows Command Palette ("CmdPal") is the next iteration of PowerToys Run. With extensibility at its core, the Command Palette is your one-stop launcher to start _anything_.

By default, CmdPal is bound to <kbd>Win+Alt+Space</kbd>.

![cmdpal-pr-002](https://github.com/user-attachments/assets/5077ec04-1009-478a-92d6-0a30989d44ac)
![cmdpal-pr-003](https://github.com/user-attachments/assets/63b4762a-9c19-48eb-9242-18ea48240ba0)

----

This brings the current preview version of CmdPal into the upstream PowerToys repo. There are still lots of bugs to work out, but it's reached the state we're ready to start sharing it with the world. From here, we can further collaborate with the community on the features that are important, and ensuring that we've got a most robust API to enable developers to build whatever extensions they want. 

Most of the built-in PT Run modules have already been ported to CmdPal's extension API. Those include:
* Installed apps
* Shell commands
* File search (powered by the indexer)
* Windows Registry search
* Web search
* Windows Terminal Profiles
* Windows Services
* Windows settings


There are a couple new extensions built-in
* You can now search for packages on `winget` and install them right from the palette. This also powers searching for extensions for the palette
* The calculator has an entirely new implementation. This is currently less feature complete than the original PT Run one - we're looking forward to updating it to be more complete for future ingestion in Windows
* "Bookmarks" allow you to save shortcuts to files, folders, and webpages as top-level commands in the palette. 

We've got a bunch of other samples too, in this repo and elsewhere

### PowerToys specific notes

CmdPal will eventually graduate out of PowerToys to live as its own application, which is why it's implemented just a little differently than most other modules. Enabling CmdPal will install its `msix` package. 

The CI was minorly changed to support CmdPal version numbers independent of PowerToys itself. It doesn't make sense for us to start CmdPal at v0.90, and in the future, we want to be able to rev CmdPal independently of PT itself. 


Closes #3200, closes #3600, closes #7770, closes #34273, closes #36471, closes #20976, closes #14495
  
  
-----

TODOs et al


**Blocking:**
- [ ] Images and descriptions in Settings and OOBE need to be properly defined, as mentioned before
  - [ ] Niels is on it
- [x] Doesn't start properly from PowerToys unless the fix PR is merged.
  - https://github.com/zadjii-msft/PowerToys/pull/556 merged
- [x] I seem to lose focus a lot when I press on some limits, like between the search bar and the results.
  - This is https://github.com/zadjii-msft/PowerToys/issues/427
- [x] Turned off an extension like Calculator and it was still working.
  - Need to get rid of that toggle, it doesn't do anything currently
- [x] `ListViewModel.<FetchItems>` crash
  - Pretty confident that was fixed in https://github.com/zadjii-msft/PowerToys/pull/553

**Not blocking / improvements:**
- Show the shortcut through settings, as mentioned before, or create a button that would open CmdPalette settings.
- When PowerToys starts, CmdPalette is always shown if enabled. That's weird when just starting PowerToys/ logging in to the computer with PowerToys auto-start activated. I think this should at least be a setting.
- Needing to double press a result for it to do the default action seems quirky. If one is already selected, I think just pressing should be enough for it to do the action.
  - This is currently a setting, though we're thinking of changing the setting even more: https://github.com/zadjii-msft/PowerToys/issues/392
- There's no URI extension. Was surprised when typing a URL that it only proposed a web search.
- [x] There's no System commands extension. Was expecting to be able to quickly restart the computer by typing restart but it wasn't there.
  - This is in PR https://github.com/zadjii-msft/PowerToys/pull/452  
  
---------

Co-authored-by: joadoumie <98557455+joadoumie@users.noreply.github.com>
Co-authored-by: Jordi Adoumie <jordiadoumie@microsoft.com>
Co-authored-by: Mike Griese <zadjii@gmail.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Co-authored-by: Stefan Markovic <57057282+stefansjfw@users.noreply.github.com>
Co-authored-by: Seraphima <zykovas91@gmail.com>
Co-authored-by: Jaime Bernardo <jaime@janeasystems.com>
Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com>
Co-authored-by: Eric Johnson <ericjohnson327@gmail.com>
Co-authored-by: Ethan Fang <ethanfang@microsoft.com>
Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Clint Rutkas <clint@rutkas.com>
This commit is contained in:
Mike Griese
2025-03-19 03:39:57 -05:00
committed by GitHub
parent a62acf7a71
commit f68f408be3
984 changed files with 69758 additions and 277 deletions

View File

@@ -0,0 +1,120 @@
// 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.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class AliasManager : ObservableObject
{
private readonly TopLevelCommandManager _topLevelCommandManager;
// REMEMBER, CommandAlias.SearchPrefix is what we use as keys
private readonly Dictionary<string, CommandAlias> _aliases;
public AliasManager(TopLevelCommandManager tlcManager, SettingsModel settings)
{
_topLevelCommandManager = tlcManager;
_aliases = settings.Aliases;
if (_aliases.Count == 0)
{
PopulateDefaultAliases();
}
}
private void AddAlias(CommandAlias a) => _aliases.Add(a.SearchPrefix, a);
public bool CheckAlias(string searchText)
{
if (_aliases.TryGetValue(searchText, out var alias))
{
try
{
var topLevelCommand = _topLevelCommandManager.LookupCommand(alias.CommandId);
if (topLevelCommand != null)
{
WeakReferenceMessenger.Default.Send<ClearSearchMessage>();
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(topLevelCommand));
return true;
}
}
catch
{
}
}
return false;
}
private void PopulateDefaultAliases()
{
this.AddAlias(new CommandAlias(":", "com.microsoft.cmdpal.registry", true));
this.AddAlias(new CommandAlias("$", "com.microsoft.cmdpal.windowsSettings", true));
this.AddAlias(new CommandAlias("=", "com.microsoft.cmdpal.calculator", true));
this.AddAlias(new CommandAlias(">", "com.microsoft.cmdpal.shell", true));
this.AddAlias(new CommandAlias("<", "com.microsoft.cmdpal.windowwalker", true));
this.AddAlias(new CommandAlias("??", "com.microsoft.cmdpal.websearch", true));
this.AddAlias(new CommandAlias("file", "com.microsoft.indexer.fileSearch", false));
this.AddAlias(new CommandAlias(")", "com.microsoft.cmdpal.timedate", true));
}
public string? KeysFromId(string commandId)
{
return _aliases
.Where(kv => kv.Value.CommandId == commandId)
.Select(kv => kv.Value.Alias)
.FirstOrDefault();
}
public CommandAlias? AliasFromId(string commandId)
{
return _aliases
.Where(kv => kv.Value.CommandId == commandId)
.Select(kv => kv.Value)
.FirstOrDefault();
}
public void UpdateAlias(string commandId, CommandAlias? newAlias)
{
if (string.IsNullOrEmpty(commandId))
{
// do nothing?
return;
}
// If we already have _this exact alias_, do nothing
if (newAlias != null &&
_aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias))
{
if (existingAlias.CommandId == commandId)
{
return;
}
}
// Look for the old alias, and remove it
List<CommandAlias> toRemove = [];
foreach (var kv in _aliases)
{
if (kv.Value.CommandId == commandId)
{
toRemove.Add(kv.Value);
}
}
foreach (var alias in toRemove)
{
// REMEMBER, SearchPrefix is what we use as keys
_aliases.Remove(alias.SearchPrefix);
}
if (newAlias != null)
{
AddAlias(newAlias);
}
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class AppStateModel : ObservableObject
{
[JsonIgnore]
public static readonly string FilePath;
public event TypedEventHandler<AppStateModel, object?>? StateChanged;
///////////////////////////////////////////////////////////////////////////
// STATE HERE
public RecentCommandsManager RecentCommands { get; private set; } = new();
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
static AppStateModel()
{
FilePath = StateJsonPath();
}
public static AppStateModel LoadState()
{
if (string.IsNullOrEmpty(FilePath))
{
throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadState)}");
}
if (!File.Exists(FilePath))
{
Debug.WriteLine("The provided settings file does not exist");
return new();
}
try
{
// Read the JSON content from the file
var jsonContent = File.ReadAllText(FilePath);
var loaded = JsonSerializer.Deserialize<AppStateModel>(jsonContent, _deserializerOptions);
Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse");
return loaded ?? new();
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return new();
}
public static void SaveState(AppStateModel model)
{
if (string.IsNullOrEmpty(FilePath))
{
throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveState)}");
}
try
{
// Serialize the main dictionary to JSON and save it to the file
var settingsJson = JsonSerializer.Serialize(model, _serializerOptions);
// Is it valid JSON?
if (JsonNode.Parse(settingsJson) is JsonObject newSettings)
{
// Now, read the existing content from the file
var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}";
// Is it valid JSON?
if (JsonNode.Parse(oldContent) is JsonObject savedSettings)
{
foreach (var item in newSettings)
{
savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null;
}
var serialized = savedSettings.ToJsonString(_serializerOptions);
File.WriteAllText(FilePath, serialized);
// TODO: Instead of just raising the event here, we should
// have a file change watcher on the settings file, and
// reload the settings then
model.StateChanged?.Invoke(model, null);
}
else
{
Debug.WriteLine("Failed to parse settings file as JsonObject.");
}
}
else
{
Debug.WriteLine("Failed to parse settings file as JsonObject.");
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}
internal static string StateJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the settings is just next to the exe
return Path.Combine(directory, "state.json");
}
private static readonly JsonSerializerOptions _serializerOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
private static readonly JsonSerializerOptions _deserializerOptions = new()
{
PropertyNameCaseInsensitive = true,
IncludeFields = true,
AllowTrailingCommas = true,
PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
ReadCommentHandling = JsonCommentHandling.Skip,
};
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
public class CommandAlias
{
public string CommandId { get; set; }
public string Alias { get; set; }
public bool IsDirect { get; set; }
[JsonIgnore]
public string SearchPrefix => Alias + (IsDirect ? string.Empty : " ");
public CommandAlias(string shortcut, string commandId, bool direct = false)
{
CommandId = commandId;
Alias = shortcut;
IsDirect = direct;
}
public CommandAlias()
: this(string.Empty, string.Empty, false)
{
}
public static CommandAlias FromSearchText(string text, string commandId)
{
var trailingSpace = text.EndsWith(' ');
var realAlias = trailingSpace ? text.Substring(0, text.Length - 1) : text;
return new CommandAlias(realAlias, commandId, !trailingSpace);
}
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandBarViewModel : ObservableObject,
IRecipient<UpdateCommandBarMessage>
{
public ICommandBarContext? SelectedItem
{
get => field;
set
{
if (field != null)
{
field.PropertyChanged -= SelectedItemPropertyChanged;
}
field = value;
SetSelectedItem(value);
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPrimaryCommand))]
public partial CommandItemViewModel? PrimaryCommand { get; set; }
public bool HasPrimaryCommand => PrimaryCommand != null && PrimaryCommand.ShouldBeVisible;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSecondaryCommand))]
public partial CommandItemViewModel? SecondaryCommand { get; set; }
public bool HasSecondaryCommand => SecondaryCommand != null;
[ObservableProperty]
public partial bool ShouldShowContextMenu { get; set; } = false;
[ObservableProperty]
public partial PageViewModel? CurrentPage { get; set; }
[ObservableProperty]
public partial ObservableCollection<CommandContextItemViewModel> ContextCommands { get; set; } = [];
public CommandBarViewModel()
{
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
}
public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel;
private void SetSelectedItem(ICommandBarContext? value)
{
if (value != null)
{
PrimaryCommand = value.PrimaryCommand;
value.PropertyChanged += SelectedItemPropertyChanged;
}
else
{
if (SelectedItem != null)
{
SelectedItem.PropertyChanged -= SelectedItemPropertyChanged;
}
PrimaryCommand = null;
}
UpdateContextItems();
}
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(SelectedItem.HasMoreCommands):
UpdateContextItems();
break;
}
}
private void UpdateContextItems()
{
if (SelectedItem == null)
{
SecondaryCommand = null;
ShouldShowContextMenu = false;
return;
}
SecondaryCommand = SelectedItem.SecondaryCommand;
if (SelectedItem.MoreCommands.Count() > 1)
{
ShouldShowContextMenu = true;
ContextCommands = [.. SelectedItem.AllCommands];
}
else
{
ShouldShowContextMenu = false;
}
}
// InvokeItemCommand is what this will be in Xaml due to source generator
// this comes in when an item in the list is tapped
[RelayCommand]
private void InvokeItem(CommandContextItemViewModel item) =>
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model));
// this comes in when the primary button is tapped
public void InvokePrimaryCommand()
{
if (PrimaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model));
}
}
// this comes in when the secondary button is tapped
public void InvokeSecondaryCommand()
{
if (SecondaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model));
}
}
}

View File

@@ -0,0 +1,42 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference<IPageContext> context) : CommandItemViewModel(new(contextItem), context)
{
public new ExtensionObject<ICommandContextItem> Model { get; } = new(contextItem);
public bool IsCritical { get; private set; }
public KeyChord? RequestedShortcut { get; private set; }
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
base.InitializeProperties();
var contextItem = Model.Unsafe;
if (contextItem == null)
{
return; // throw?
}
IsCritical = contextItem.IsCritical;
if (contextItem.RequestedShortcut != null)
{
RequestedShortcut = new(
contextItem.RequestedShortcut.Modifiers,
contextItem.RequestedShortcut.Vkey,
contextItem.RequestedShortcut.ScanCode);
}
}
}

View File

@@ -0,0 +1,408 @@
// 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.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext
{
public ExtensionObject<ICommandItem> Model => _commandItemModel;
private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
private CommandContextItemViewModel? _defaultCommandContextItem;
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
protected bool IsFastInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.FastInitialized);
protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
protected bool IsSelectedInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.SelectionInitialized);
public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
// These are properties that are "observable" from the extension object
// itself, in the sense that they get raised by PropChanged events from the
// extension. However, we don't want to actually make them
// [ObservableProperty]s, because PropChanged comes in off the UI thread,
// and ObservableProperty is not smart enough to raise the PropertyChanged
// on the UI thread.
public string Name => Command.Name;
private string _itemTitle = string.Empty;
public string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
public string Subtitle { get; private set; } = string.Empty;
private IconInfoViewModel _listItemIcon = new(null);
public IconInfoViewModel Icon => _listItemIcon.IsSet ? _listItemIcon : Command.Icon;
public CommandViewModel Command { get; private set; }
public List<CommandContextItemViewModel> MoreCommands { get; private set; } = [];
IEnumerable<CommandContextItemViewModel> ICommandBarContext.MoreCommands => MoreCommands;
public bool HasMoreCommands => MoreCommands.Count > 0;
public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
public CommandItemViewModel? PrimaryCommand => this;
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? MoreCommands[0] : null;
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
public List<CommandContextItemViewModel> AllCommands
{
get
{
List<CommandContextItemViewModel> l = _defaultCommandContextItem == null ?
new() :
[_defaultCommandContextItem];
l.AddRange(MoreCommands);
return l;
}
}
private static readonly IconInfoViewModel _errorIcon;
static CommandItemViewModel()
{
_errorIcon = new(new IconInfo("\uEA39")); // ErrorBadge
_errorIcon.InitializeProperties();
}
public CommandItemViewModel(ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext)
: base(errorContext)
{
_commandItemModel = item;
Command = new(null, errorContext);
}
public void FastInitializeProperties()
{
if (IsFastInitialized)
{
return;
}
var model = _commandItemModel.Unsafe;
if (model == null)
{
return;
}
Command = new(model.Command, PageContext);
Command.FastInitializeProperties();
_itemTitle = model.Title;
Subtitle = model.Subtitle;
Initialized |= InitializedState.FastInitialized;
}
//// Called from ListViewModel on background thread started in ListPage.xaml.cs
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
if (!IsFastInitialized)
{
FastInitializeProperties();
}
var model = _commandItemModel.Unsafe;
if (model == null)
{
return;
}
Command.InitializeProperties();
var listIcon = model.Icon;
if (listIcon != null)
{
_listItemIcon = new(listIcon);
_listItemIcon.InitializeProperties();
}
// TODO: Do these need to go into FastInit?
model.PropChanged += Model_PropChanged;
Command.PropertyChanged += Command_PropertyChanged;
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Subtitle));
UpdateProperty(nameof(Icon));
UpdateProperty(nameof(IsInitialized));
Initialized |= InitializedState.Initialized;
}
public void SlowInitializeProperties()
{
if (IsSelectedInitialized)
{
return;
}
if (!IsInitialized)
{
InitializeProperties();
}
var model = _commandItemModel.Unsafe;
if (model == null)
{
return;
}
var more = model.MoreCommands;
if (more != null)
{
MoreCommands = more
.Where(contextItem => contextItem is ICommandContextItem)
.Select(contextItem => (contextItem as ICommandContextItem)!)
.Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext))
.ToList();
}
// Here, we're already theoretically in the async context, so we can
// use Initialize straight up
MoreCommands.ForEach(contextItem =>
{
contextItem.InitializeProperties();
});
_defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext)
{
_itemTitle = Name,
Subtitle = Subtitle,
Command = Command,
// TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever
};
// Only set the icon on the context item for us if our command didn't
// have its own icon
if (!Command.HasIcon)
{
_defaultCommandContextItem._listItemIcon = _listItemIcon;
}
Initialized |= InitializedState.SelectionInitialized;
UpdateProperty(nameof(MoreCommands));
UpdateProperty(nameof(AllCommands));
UpdateProperty(nameof(IsSelectedInitialized));
}
public bool SafeFastInit()
{
try
{
FastInitializeProperties();
return true;
}
catch (Exception)
{
Command = new(null, PageContext);
_itemTitle = "Error";
Subtitle = "Item failed to load";
MoreCommands = [];
_listItemIcon = _errorIcon;
Initialized |= InitializedState.Error;
}
return false;
}
public bool SafeSlowInit()
{
try
{
SlowInitializeProperties();
return true;
}
catch (Exception)
{
Initialized |= InitializedState.Error;
}
return false;
}
public bool SafeInitializeProperties()
{
try
{
InitializeProperties();
return true;
}
catch (Exception)
{
Command = new(null, PageContext);
_itemTitle = "Error";
Subtitle = "Item failed to load";
MoreCommands = [];
_listItemIcon = _errorIcon;
Initialized |= InitializedState.Error;
}
return false;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex, _commandItemModel?.Unsafe?.Title);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this._commandItemModel.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Command):
if (Command != null)
{
Command.PropertyChanged -= Command_PropertyChanged;
}
Command = new(model.Command, PageContext);
Command.InitializeProperties();
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
break;
case nameof(Title):
_itemTitle = model.Title;
break;
case nameof(Subtitle):
this.Subtitle = model.Subtitle;
break;
case nameof(Icon):
_listItemIcon = new(model.Icon);
_listItemIcon.InitializeProperties();
break;
case nameof(model.MoreCommands):
var more = model.MoreCommands;
if (more != null)
{
var newContextMenu = more
.Where(contextItem => contextItem is ICommandContextItem)
.Select(contextItem => (contextItem as ICommandContextItem)!)
.Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext))
.ToList();
lock (MoreCommands)
{
ListHelpers.InPlaceUpdateList(MoreCommands, newContextMenu);
}
newContextMenu.ForEach(contextItem =>
{
contextItem.InitializeProperties();
});
}
else
{
lock (MoreCommands)
{
MoreCommands.Clear();
}
}
UpdateProperty(nameof(SecondaryCommand));
UpdateProperty(nameof(SecondaryCommandName));
UpdateProperty(nameof(HasMoreCommands));
break;
}
UpdateProperty(propertyName);
}
private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var propertyName = e.PropertyName;
switch (propertyName)
{
case nameof(Command.Name):
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Name));
break;
case nameof(Command.Icon):
UpdateProperty(nameof(Icon));
break;
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
lock (MoreCommands)
{
MoreCommands.ForEach(c => c.SafeCleanup());
MoreCommands.Clear();
}
// _listItemIcon.SafeCleanup();
_listItemIcon = new(null); // necessary?
_defaultCommandContextItem?.SafeCleanup();
_defaultCommandContextItem = null;
Command.PropertyChanged -= Command_PropertyChanged;
Command.SafeCleanup();
var model = _commandItemModel.Unsafe;
if (model != null)
{
model.PropChanged -= Model_PropChanged;
}
}
public override void SafeCleanup()
{
base.SafeCleanup();
Initialized |= InitializedState.CleanedUp;
}
}
[Flags]
internal enum InitializedState
{
Uninitialized = 0,
FastInitialized = 1,
Initialized = 2,
SelectionInitialized = 4,
Error = 8,
CleanedUp = 16,
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Diagnostics;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class CommandPaletteHost : IExtensionHost
{
// Static singleton, so that we can access this from anywhere
// Post MVVM - this should probably be like, a dependency injection thing.
public static CommandPaletteHost Instance { get; } = new();
private static readonly GlobalLogPageContext _globalLogPageContext = new();
private static ulong _hostingHwnd;
public ulong HostingHwnd => _hostingHwnd;
public string LanguageOverride => string.Empty;
public static ObservableCollection<LogMessageViewModel> LogMessages { get; } = [];
public ObservableCollection<StatusMessageViewModel> StatusMessages { get; } = [];
public IExtensionWrapper? Extension { get; }
private readonly ICommandProvider? _builtInProvider;
private CommandPaletteHost()
{
}
public CommandPaletteHost(IExtensionWrapper source)
{
Extension = source;
}
public CommandPaletteHost(ICommandProvider builtInProvider)
{
_builtInProvider = builtInProvider;
}
public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Debug.WriteLine(message.Message);
_ = Task.Run(() =>
{
ProcessStatusMessage(message, context);
});
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction HideStatus(IStatusMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
_ = Task.Run(() =>
{
ProcessHideStatusMessage(message);
});
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction LogMessage(ILogMessage? message)
{
if (message == null)
{
return Task.CompletedTask.AsAsyncAction();
}
Logger.LogDebug(message.Message);
_ = Task.Run(() =>
{
ProcessLogMessage(message);
});
// We can't just make a LogMessageViewModel : ExtensionObjectViewModel
// because we don't necessarily know the page context. Butts.
return Task.CompletedTask.AsAsyncAction();
}
public void ProcessLogMessage(ILogMessage message)
{
var vm = new LogMessageViewModel(message, _globalLogPageContext);
vm.SafeInitializePropertiesSynchronous();
if (Extension != null)
{
vm.ExtensionPfn = Extension.PackageFamilyName;
}
Task.Factory.StartNew(
() =>
{
LogMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessStatusMessage(IStatusMessage message, StatusContext context)
{
// If this message is already in the list of messages, just bring it to the top
var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (oldVm != null)
{
Task.Factory.StartNew(
() =>
{
StatusMessages.Remove(oldVm);
StatusMessages.Add(oldVm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
return;
}
var vm = new StatusMessageViewModel(message, new(_globalLogPageContext));
vm.SafeInitializePropertiesSynchronous();
if (Extension != null)
{
vm.ExtensionPfn = Extension.PackageFamilyName;
}
Task.Factory.StartNew(
() =>
{
StatusMessages.Add(vm);
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public void ProcessHideStatusMessage(IStatusMessage message)
{
Task.Factory.StartNew(
() =>
{
try
{
var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault();
if (vm != null)
{
StatusMessages.Remove(vm);
}
}
catch
{
}
},
CancellationToken.None,
TaskCreationOptions.None,
_globalLogPageContext.Scheduler);
}
public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd;
public void DebugLog(string message)
{
#if DEBUG
this.ProcessLogMessage(new LogMessage(message));
#endif
}
public void Log(string message)
{
this.ProcessLogMessage(new LogMessage(message));
}
}

View File

@@ -0,0 +1,185 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class CommandProviderWrapper
{
public bool IsExtension => Extension != null;
private readonly bool isValid;
private readonly ExtensionObject<ICommandProvider> _commandProvider;
private readonly TaskScheduler _taskScheduler;
public ICommandItem[] TopLevelItems { get; private set; } = [];
public IFallbackCommandItem[] FallbackItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty;
public IExtensionWrapper? Extension { get; }
public CommandPaletteHost ExtensionHost { get; private set; }
public event TypedEventHandler<CommandProviderWrapper, IItemsChangedEventArgs>? CommandsChanged;
public string Id { get; private set; } = string.Empty;
public IconInfoViewModel Icon { get; private set; } = new(null);
public CommandSettingsViewModel? Settings { get; private set; }
public string ProviderId => $"{Extension?.PackageFamilyName ?? string.Empty}/{Id}";
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
{
// This ctor is only used for in-proc builtin commands. So the Unsafe!
// calls are pretty dang safe actually.
_commandProvider = new(provider);
_taskScheduler = mainThread;
// Hook the extension back into us
ExtensionHost = new CommandPaletteHost(provider);
_commandProvider.Unsafe!.InitializeWithHost(ExtensionHost);
_commandProvider.Unsafe!.ItemsChanged += CommandProvider_ItemsChanged;
isValid = true;
Id = provider.Id;
DisplayName = provider.DisplayName;
Icon = new(provider.Icon);
Icon.InitializeProperties();
Settings = new(provider.Settings, this, _taskScheduler);
Settings.InitializeProperties();
Logger.LogDebug($"Initialized command provider {ProviderId}");
}
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
{
_taskScheduler = mainThread;
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
if (!Extension.IsRunning())
{
throw new ArgumentException("You forgot to start the extension. This is a CmdPal error - we need to make sure to call StartExtensionAsync");
}
var extensionImpl = extension.GetExtensionObject();
var providerObject = extensionImpl?.GetProvider(ProviderType.Commands);
if (providerObject is not ICommandProvider provider)
{
throw new ArgumentException("extension didn't actually implement ICommandProvider");
}
_commandProvider = new(provider);
try
{
var model = _commandProvider.Unsafe!;
// Hook the extension back into us
model.InitializeWithHost(ExtensionHost);
model.ItemsChanged += CommandProvider_ItemsChanged;
isValid = true;
Logger.LogDebug($"Initialized extension command provider {Extension.PackageFamilyName}:{Extension.ExtensionUniqueId}");
}
catch (Exception e)
{
Logger.LogError("Failed to initialize CommandProvider for extension.");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
isValid = true;
}
public async Task LoadTopLevelCommands()
{
if (!isValid)
{
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
try
{
var model = _commandProvider.Unsafe!;
var t = new Task<ICommandItem[]>(model.TopLevelCommands);
t.Start();
commands = await t.ConfigureAwait(false);
// On a BG thread here
fallbacks = model.FallbackCommands();
Id = model.Id;
DisplayName = model.DisplayName;
Icon = new(model.Icon);
Icon.InitializeProperties();
Settings = new(model.Settings, this, _taskScheduler);
Settings.InitializeProperties();
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
catch (Exception e)
{
Logger.LogError("Failed to load commands from extension");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
if (commands != null)
{
TopLevelItems = commands;
}
if (fallbacks != null)
{
FallbackItems = fallbacks;
}
}
/* This is a View/ExtensionHost piece
* public void AllowSetForeground(bool allow)
{
if (!IsExtension)
{
return;
}
var iextn = extensionWrapper?.GetExtensionObject();
unsafe
{
PInvoke.CoAllowSetForegroundWindow(iextn);
}
}*/
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args) =>
// We don't want to handle this ourselves - we want the
// TopLevelCommandManager to know about this, so they can remove
// our old commands from their own list.
//
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
}

View File

@@ -0,0 +1,30 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandSettingsViewModel(ICommandSettings _unsafeSettings, CommandProviderWrapper provider, TaskScheduler mainThread)
{
private readonly ExtensionObject<ICommandSettings> _model = new(_unsafeSettings);
public ContentPageViewModel? SettingsPage { get; private set; }
public void InitializeProperties()
{
var model = _model.Unsafe;
if (model == null)
{
return;
}
if (model.SettingsPage is IContentPage page)
{
SettingsPage = new(page, mainThread, provider.ExtensionHost);
SettingsPage.InitializeProperties();
}
}
}

View File

@@ -0,0 +1,132 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandViewModel : ExtensionObjectViewModel
{
public ExtensionObject<ICommand> Model { get; private set; } = new(null);
protected bool IsInitialized { get; private set; }
protected bool IsFastInitialized { get; private set; }
public bool HasIcon => Icon.IsSet;
// These are properties that are "observable" from the extension object
// itself, in the sense that they get raised by PropChanged events from the
// extension. However, we don't want to actually make them
// [ObservableProperty]s, because PropChanged comes in off the UI thread,
// and ObservableProperty is not smart enough to raise the PropertyChanged
// on the UI thread.
public string Id { get; private set; } = string.Empty;
public string Name { get; private set; } = string.Empty;
public IconInfoViewModel Icon { get; private set; }
public CommandViewModel(ICommand? command, WeakReference<IPageContext> pageContext)
: base(pageContext)
{
Model = new(command);
Icon = new(null);
}
public void FastInitializeProperties()
{
if (IsFastInitialized)
{
return;
}
var model = Model.Unsafe;
if (model == null)
{
return;
}
Name = model.Name ?? string.Empty;
IsFastInitialized = true;
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
if (!IsFastInitialized)
{
FastInitializeProperties();
}
var model = Model.Unsafe;
if (model == null)
{
return;
}
var ico = model.Icon;
if (ico != null)
{
Icon = new(ico);
Icon.InitializeProperties();
UpdateProperty(nameof(Icon));
}
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex, Name);
}
}
protected void FetchProperty(string propertyName)
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Name):
Name = model.Name;
break;
case nameof(Icon):
var iconInfo = model.Icon;
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
}
UpdateProperty(propertyName);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
Icon = new(null); // necessary?
var model = Model.Unsafe;
if (model != null)
{
model.PropChanged -= Model_PropChanged;
}
}
}

View File

@@ -0,0 +1,43 @@
// 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.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
/// <summary>
/// Built-in Provider for a top-level command which can quit the application. Invokes the <see cref="QuitCommand"/>, which sends a <see cref="QuitMessage"/>.
/// </summary>
public partial class BuiltInsCommandProvider : CommandProvider
{
private readonly OpenSettingsCommand openSettings = new();
private readonly QuitCommand quitCommand = new();
private readonly FallbackReloadItem _fallbackReloadItem = new();
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { Subtitle = Properties.Resources.builtin_open_settings_subtitle },
new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = Properties.Resources.builtin_new_extension_subtitle },
];
public override IFallbackCommandItem[] FallbackCommands() =>
[
new FallbackCommandItem(quitCommand, displayTitle: Properties.Resources.builtin_quit_subtitle) { Subtitle = Properties.Resources.builtin_quit_subtitle },
_fallbackReloadItem,
_fallbackLogItem,
];
public BuiltInsCommandProvider()
{
Id = "Core";
DisplayName = Properties.Resources.builtin_display_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class BuiltinsExtensionHost
{
internal static ExtensionHostInstance Instance { get; } = new();
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class CreatedExtensionForm : NewExtensionFormBase
{
public CreatedExtensionForm(string name, string displayName, string path)
{
TemplateJson = CardTemplate;
DataJson = $$"""
{
"name": {{JsonSerializer.Serialize(name)}},
"directory": {{JsonSerializer.Serialize(path)}},
"displayName": {{JsonSerializer.Serialize(displayName)}}
}
""";
_name = name;
_displayName = displayName;
_path = path;
}
public override ICommandResult SubmitForm(string inputs, string data)
{
JsonObject? dataInput = JsonNode.Parse(data)?.AsObject();
if (dataInput == null)
{
return CommandResult.KeepOpen();
}
string verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty;
return verb switch
{
"sln" => OpenSolution(),
"dir" => OpenDirectory(),
"new" => CreateNew(),
_ => CommandResult.KeepOpen(),
};
}
private ICommandResult OpenSolution()
{
string[] parts = [_path, _name, $"{_name}.sln"];
string pathToSolution = Path.Combine(parts);
ShellHelpers.OpenInShell(pathToSolution);
return CommandResult.Hide();
}
private ICommandResult OpenDirectory()
{
string[] parts = [_path, _name];
string pathToDir = Path.Combine(parts);
ShellHelpers.OpenInShell(pathToDir);
return CommandResult.Hide();
}
private ICommandResult CreateNew()
{
RaiseFormSubmit(null);
return CommandResult.KeepOpen();
}
private static readonly string CardTemplate = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_success}}",
"size": "large",
"weight": "bolder",
"style": "heading",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_created_in_text}}",
"wrap": true
},
{
"type": "TextBlock",
"text": "${directory}",
"fontType": "monospace"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_created_next_steps_title}}",
"style": "heading",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_created_next_steps}}",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_created_next_steps_p2}}",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_created_next_steps_p3}}",
"wrap": true
}
],
"actions": [
{
"type": "Action.Submit",
"title": "{{Properties.Resources.builtin_create_extension_open_solution}}",
"data": {
"x": "sln"
}
},
{
"type": "Action.Submit",
"title": "{{Properties.Resources.builtin_create_extension_open_directory}}",
"data": {
"x": "dir"
}
},
{
"type": "Action.Submit",
"title": "{{Properties.Resources.builtin_create_extension_create_another}}",
"data": {
"x": "new"
}
}
]
}
""";
private readonly string _name;
private readonly string _displayName;
private readonly string _path;
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class FallbackLogItem : FallbackCommandItem
{
private readonly LogMessagesPage _logMessagesPage;
public FallbackLogItem()
: base(new LogMessagesPage(), Resources.builtin_log_subtitle)
{
_logMessagesPage = (LogMessagesPage)Command!;
Title = string.Empty;
_logMessagesPage.Name = string.Empty;
Subtitle = Properties.Resources.builtin_log_subtitle;
}
public override void UpdateQuery(string query)
{
_logMessagesPage.Name = query.StartsWith('l') ? Properties.Resources.builtin_log_title : string.Empty;
Title = _logMessagesPage.Name;
}
}

View File

@@ -0,0 +1,26 @@
// 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.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class FallbackReloadItem : FallbackCommandItem
{
private readonly ReloadExtensionsCommand _reloadCommand;
public FallbackReloadItem()
: base(new ReloadExtensionsCommand(), Properties.Resources.builtin_reload_display_title)
{
_reloadCommand = (ReloadExtensionsCommand)Command!;
Title = string.Empty;
Subtitle = Properties.Resources.builtin_reload_subtitle;
}
public override void UpdateQuery(string query)
{
_reloadCommand.Name = query.StartsWith('r') ? "Reload" : string.Empty;
Title = _reloadCommand.Name;
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Specialized;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Commands;
public partial class LogMessagesPage : ListPage
{
private readonly List<IListItem> _listItems = new();
public LogMessagesPage()
{
Name = Properties.Resources.builtin_log_name;
Title = Properties.Resources.builtin_log_page_name;
Icon = new IconInfo("\uE8FD"); // BulletedList icon
CommandPaletteHost.LogMessages.CollectionChanged += LogMessages_CollectionChanged;
}
private void LogMessages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null)
{
foreach (var item in e.NewItems)
{
if (item is LogMessageViewModel logMessageViewModel)
{
var li = new ListItem(new NoOpCommand())
{
Title = logMessageViewModel.Message,
Subtitle = logMessageViewModel.ExtensionPfn,
};
_listItems.Insert(0, li);
}
}
RaiseItemsChanged(_listItems.Count);
}
}
public override IListItem[] GetItems()
{
return _listItems.ToArray();
}
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// <summary>
/// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a <see cref="ListPage"/>.
/// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
/// </summary>
public partial class MainListPage : DynamicListPage,
IRecipient<ClearSearchMessage>,
IRecipient<UpdateFallbackItemsMessage>
{
private readonly IServiceProvider _serviceProvider;
private readonly TopLevelCommandManager _tlcManager;
private IEnumerable<IListItem>? _filteredItems;
public MainListPage(IServiceProvider serviceProvider)
{
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
_serviceProvider = serviceProvider;
_tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
// The all apps page will kick off a BG thread to start loading apps.
// We just want to know when it is done.
var allApps = AllAppsCommandProvider.Page;
allApps.PropChanged += (s, p) =>
{
if (p.PropertyName == nameof(allApps.IsLoading))
{
IsLoading = ActuallyLoading();
}
};
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
var settings = _serviceProvider.GetService<SettingsModel>()!;
settings.SettingsChanged += SettingsChangedHandler;
HotReloadSettings(settings);
IsLoading = true;
}
private void TlcManager_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(IsLoading))
{
IsLoading = ActuallyLoading();
}
}
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
public override IListItem[] GetItems()
{
if (string.IsNullOrEmpty(SearchText))
{
lock (_tlcManager.TopLevelCommands)
{
return _tlcManager
.TopLevelCommands
.Select(tlc => tlc)
.Where(tlc => !string.IsNullOrEmpty(tlc.Title))
.ToArray();
}
}
else
{
lock (_tlcManager.TopLevelCommands)
{
return _filteredItems?.ToArray() ?? [];
}
}
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
// Handle changes to the filter text here
if (!string.IsNullOrEmpty(SearchText))
{
var aliases = _serviceProvider.GetService<AliasManager>()!;
if (aliases.CheckAlias(newSearch))
{
return;
}
}
var commands = _tlcManager.TopLevelCommands;
lock (commands)
{
// This gets called on a background thread, because ListViewModel
// updates the .SearchText of all extensions on a BG thread.
foreach (var command in commands)
{
command.TryUpdateFallbackText(newSearch);
}
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
if (string.IsNullOrEmpty(newSearch))
{
_filteredItems = null;
RaiseItemsChanged(commands.Count);
return;
}
// If the new string doesn't start with the old string, then we can't
// re-use previous results. Reset _filteredItems, and keep er moving.
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
{
_filteredItems = null;
}
// If we don't have any previous filter results to work with, start
// with a list of all our commands & apps.
if (_filteredItems == null)
{
IEnumerable<IListItem> apps = AllAppsCommandProvider.Page.GetItems();
_filteredItems = commands.Concat(apps);
}
// Produce a list of everything that matches the current filter.
_filteredItems = ListHelpers.FilterList<IListItem>(_filteredItems, SearchText, ScoreTopLevelItem);
RaiseItemsChanged(_filteredItems.Count());
}
}
private bool ActuallyLoading()
{
var tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
var allApps = AllAppsCommandProvider.Page;
return allApps.IsLoading || tlcManager.IsLoading;
}
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first.
private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem)
{
if (string.IsNullOrWhiteSpace(query))
{
return 1;
}
var title = topLevelOrAppItem.Title;
if (string.IsNullOrEmpty(title))
{
return 0;
}
var isFallback = false;
var isAliasSubstringMatch = false;
var isAliasMatch = false;
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var extensionDisplayName = string.Empty;
if (topLevelOrAppItem is TopLevelCommandItemWrapper toplevel)
{
isFallback = toplevel.IsFallback;
if (toplevel.Alias?.Alias is string alias)
{
isAliasMatch = alias == query;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
}
extensionDisplayName = toplevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
}
var nameMatch = StringMatcher.FuzzySearch(query, title);
var descriptionMatch = StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle);
var extensionTitleMatch = StringMatcher.FuzzySearch(query, extensionDisplayName);
var scores = new[]
{
nameMatch.Score,
(descriptionMatch.Score - 4) / 2.0,
isFallback ? 1 : 0, // Always give fallbacks a chance...
};
var max = scores.Max();
max = max + (extensionTitleMatch.Score / 1.5);
// ... but downweight them
var matchSomething = (max / (isFallback ? 3 : 1))
+ (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
// If we matched title, subtitle, or alias (something real), then
// here we add the recent command weight boost
//
// Otherwise something like `x` will still match everything you've run before
var finalScore = matchSomething;
if (matchSomething > 0)
{
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands;
var recentWeightBoost = history.GetCommandHistoryWeight(id);
finalScore += recentWeightBoost;
}
return (int)finalScore;
}
public void UpdateHistory(IListItem topLevelOrAppItem)
{
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var state = _serviceProvider.GetService<AppStateModel>()!;
var history = state.RecentCommands;
history.AddHistoryItem(id);
AppStateModel.SaveState(state);
}
private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
{
if (topLevelOrAppItem is TopLevelCommandItemWrapper toplevel)
{
return toplevel.Id;
}
else
{
// we've got an app here
return topLevelOrAppItem.Command?.Id ?? string.Empty;
}
}
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
}

View File

@@ -0,0 +1,195 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.IO.Compression;
using System.Text.Json.Nodes;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class NewExtensionForm : NewExtensionFormBase
{
private static readonly string _creatingText = "Creating new extension...";
private readonly StatusMessage _creatingMessage = new()
{
Message = _creatingText,
Progress = new ProgressState() { IsIndeterminate = true },
};
public NewExtensionForm()
{
TemplateJson = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_page_title}}",
"size": "large"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_page_text}}",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_name_header}}",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_name_description}}",
"wrap": true
},
{
"type": "Input.Text",
"label": "{{Properties.Resources.builtin_create_extension_name_label}}",
"isRequired": true,
"errorMessage": "{{Properties.Resources.builtin_create_extension_name_required}}",
"id": "ExtensionName",
"placeholder": "ExtensionName",
"regex": "^[^\\s]+$"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_display_name_header}}",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_display_name_description}}",
"wrap": true
},
{
"type": "Input.Text",
"label": "{{Properties.Resources.builtin_create_extension_display_name_label}}",
"isRequired": true,
"errorMessage": "{{Properties.Resources.builtin_create_extension_display_name_required}}",
"id": "DisplayName",
"placeholder": "My new extension"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_directory_header}}",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "{{Properties.Resources.builtin_create_extension_directory_description}}",
"wrap": true
},
{
"type": "Input.Text",
"label": "{{Properties.Resources.builtin_create_extension_directory_label}}",
"isRequired": true,
"errorMessage": "{{Properties.Resources.builtin_create_extension_directory_required}}",
"id": "OutputPath",
"placeholder": "C:\\users\\me\\dev"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "{{Properties.Resources.builtin_create_extension_submit}}",
"associatedInputs": "auto"
}
]
}
""";
}
public override CommandResult SubmitForm(string payload)
{
var formInput = JsonNode.Parse(payload)?.AsObject();
if (formInput == null)
{
return CommandResult.KeepOpen();
}
var extensionName = formInput["ExtensionName"]?.AsValue()?.ToString() ?? string.Empty;
var displayName = formInput["DisplayName"]?.AsValue()?.ToString() ?? string.Empty;
var outputPath = formInput["OutputPath"]?.AsValue()?.ToString() ?? string.Empty;
_creatingMessage.State = MessageState.Info;
_creatingMessage.Message = _creatingText;
_creatingMessage.Progress = new ProgressState() { IsIndeterminate = true };
BuiltinsExtensionHost.Instance.ShowStatus(_creatingMessage, StatusContext.Extension);
try
{
CreateExtension(extensionName, displayName, outputPath);
BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
RaiseFormSubmit(new CreatedExtensionForm(extensionName, displayName, outputPath));
}
catch (Exception e)
{
BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
_creatingMessage.State = MessageState.Error;
_creatingMessage.Message = $"Error: {e.Message}";
}
return CommandResult.KeepOpen();
}
private void CreateExtension(string extensionName, string newDisplayName, string outputPath)
{
var newGuid = Guid.NewGuid().ToString();
// Unzip `template.zip` to a temp dir:
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
// Does the output path exist?
if (!Directory.Exists(outputPath))
{
Directory.CreateDirectory(outputPath);
}
var assetsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory.ToString(), "Microsoft.CmdPal.UI.ViewModels\\Assets\\template.zip");
ZipFile.ExtractToDirectory(assetsPath, tempDir);
var files = Directory.GetFiles(tempDir, "*", SearchOption.AllDirectories);
foreach (var file in files)
{
var text = File.ReadAllText(file);
// Replace all the instances of `FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF` with a new random guid:
text = text.Replace("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", newGuid);
// Then replace all the `TemplateCmdPalExtension` with `extensionName`
text = text.Replace("TemplateCmdPalExtension", extensionName);
// Then replace all the `TemplateDisplayName` with `newDisplayName`
text = text.Replace("TemplateDisplayName", newDisplayName);
// We're going to write the file to the same relative location in the output path
var relativePath = Path.GetRelativePath(tempDir, file);
var newFileName = Path.Combine(outputPath, relativePath);
// if the file name had `TemplateCmdPalExtension` in it, replace it with `extensionName`
newFileName = newFileName.Replace("TemplateCmdPalExtension", extensionName);
// Make sure the directory exists
Directory.CreateDirectory(Path.GetDirectoryName(newFileName)!);
File.WriteAllText(newFileName, text);
// Delete the old file
File.Delete(file);
}
// Delete the temp dir
Directory.Delete(tempDir, true);
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal abstract partial class NewExtensionFormBase : FormContent
{
public event TypedEventHandler<NewExtensionFormBase, NewExtensionFormBase?>? FormSubmitted;
protected void RaiseFormSubmit(NewExtensionFormBase? next) => FormSubmitted?.Invoke(this, next);
}

View File

@@ -0,0 +1,49 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class NewExtensionPage : ContentPage
{
private NewExtensionForm _inputForm = new();
private NewExtensionFormBase? _resultForm;
public override IContent[] GetContent()
{
return _resultForm != null ? [_resultForm] : [_inputForm];
}
public NewExtensionPage()
{
Name = Properties.Resources.builtin_create_extension_name;
Title = Properties.Resources.builtin_create_extension_title;
Icon = new IconInfo("\uEA86"); // Puzzle
_inputForm.FormSubmitted += FormSubmitted;
}
private void FormSubmitted(NewExtensionFormBase sender, NewExtensionFormBase? args)
{
if (_resultForm != null)
{
_resultForm.FormSubmitted -= FormSubmitted;
}
_resultForm = args;
if (_resultForm != null)
{
_resultForm.FormSubmitted += FormSubmitted;
}
else
{
_inputForm = new();
_inputForm.FormSubmitted += FormSubmitted;
}
RaiseItemsChanged(1);
}
}

View File

@@ -0,0 +1,25 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class OpenSettingsCommand : InvokableCommand
{
public OpenSettingsCommand()
{
Name = Properties.Resources.builtin_open_settings_name;
Icon = new IconInfo("\uE713");
}
public override ICommandResult Invoke()
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
return CommandResult.KeepOpen();
}
}

View File

@@ -0,0 +1,27 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class QuitCommand : InvokableCommand, IFallbackHandler
{
public QuitCommand()
{
Icon = new IconInfo("\uE711");
}
public override ICommandResult Invoke()
{
WeakReferenceMessenger.Default.Send<QuitMessage>();
return CommandResult.KeepOpen();
}
// this sneaky hidden behavior, I'm not event gonna try to localize this.
public void UpdateQuery(string query) => Name = query.StartsWith('q') ? "Quit" : string.Empty;
}

View File

@@ -0,0 +1,27 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class ReloadExtensionsCommand : InvokableCommand
{
public ReloadExtensionsCommand()
{
Icon = new IconInfo("\uE72C"); // Refresh icon
}
public override ICommandResult Invoke()
{
// 1% BODGY: clear the search before reloading, so that we tell in-proc
// fallback handlers the empty search text
WeakReferenceMessenger.Default.Send<ClearSearchMessage>();
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>();
return CommandResult.GoHome();
}
}

View File

@@ -0,0 +1,44 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ConfirmResultViewModel(IConfirmationArgs _args, WeakReference<IPageContext> context) :
ExtensionObjectViewModel(context)
{
public ExtensionObject<IConfirmationArgs> Model { get; } = new(_args);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public bool IsPrimaryCommandCritical { get; private set; }
public CommandViewModel PrimaryCommand { get; private set; } = new(null, context);
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return;
}
Title = model.Title;
Description = model.Description;
IsPrimaryCommandCritical = model.IsPrimaryCommandCritical;
PrimaryCommand = new(model.PrimaryCommand, PageContext);
PrimaryCommand.InitializeProperties();
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Description));
UpdateProperty(nameof(IsPrimaryCommandCritical));
UpdateProperty(nameof(PrimaryCommand));
}
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using AdaptiveCards.ObjectModel.WinUI3;
using AdaptiveCards.Templating;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Windows.Data.Json;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPageContext> context) :
ContentViewModel(context)
{
private readonly ExtensionObject<IFormContent> _formModel = new(_form);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string TemplateJson { get; protected set; } = "{}";
public string StateJson { get; protected set; } = "{}";
public string DataJson { get; protected set; } = "{}";
public AdaptiveCardParseResult? Card { get; private set; }
public override void InitializeProperties()
{
var model = _formModel.Unsafe;
if (model == null)
{
return;
}
try
{
TemplateJson = model.TemplateJson;
StateJson = model.StateJson;
DataJson = model.DataJson;
AdaptiveCardTemplate template = new(TemplateJson);
var cardJson = template.Expand(DataJson);
Card = AdaptiveCard.FromJsonString(cardJson);
}
catch (Exception e)
{
// If we fail to parse the card JSON, then display _our own card_
// with the exception
AdaptiveCardTemplate template = new(ErrorCardJson);
// todo: we could probably stick Card.Errors in there too
var dataJson = $$"""
{
"error_message": {{JsonSerializer.Serialize(e.Message)}},
"error_stack": {{JsonSerializer.Serialize(e.StackTrace)}},
"inner_exception": {{JsonSerializer.Serialize(e.InnerException?.Message)}},
"template_json": {{JsonSerializer.Serialize(TemplateJson)}},
"data_json": {{JsonSerializer.Serialize(DataJson)}}
}
""";
var cardJson = template.Expand(dataJson);
Card = AdaptiveCard.FromJsonString(cardJson);
}
UpdateProperty(nameof(Card));
}
public void HandleSubmit(IAdaptiveActionElement action, JsonObject inputs)
{
if (action is AdaptiveOpenUrlAction openUrlAction)
{
WeakReferenceMessenger.Default.Send<LaunchUriMessage>(new(openUrlAction.Url));
return;
}
if (action is AdaptiveSubmitAction or AdaptiveExecuteAction)
{
// Get the data and inputs
var dataString = (action as AdaptiveSubmitAction)?.DataJson.Stringify() ?? string.Empty;
var inputString = inputs.Stringify();
_ = Task.Run(() =>
{
try
{
var model = _formModel.Unsafe!;
if (model != null)
{
var result = model.SubmitForm(inputString, dataString);
WeakReferenceMessenger.Default.Send<HandleCommandResultMessage>(new(new(result)));
}
}
catch (Exception ex)
{
ShowException(ex);
}
});
}
}
private static readonly string ErrorCardJson = """
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Error parsing form from extension",
"wrap": true,
"style": "heading",
"size": "ExtraLarge",
"weight": "Bolder",
"color": "Attention"
},
{
"type": "TextBlock",
"wrap": true,
"text": "${error_message}",
"color": "Attention"
},
{
"type": "TextBlock",
"text": "${error_stack}",
"fontType": "Monospace"
},
{
"type": "TextBlock",
"wrap": true,
"text": "Inner exception:"
},
{
"type": "TextBlock",
"wrap": true,
"text": "${inner_exception}",
"color": "Attention"
}
]
}
""";
}

View File

@@ -0,0 +1,73 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakReference<IPageContext> context) :
ContentViewModel(context)
{
public ExtensionObject<IMarkdownContent> Model { get; } = new(_markdown);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string Body { get; protected set; } = string.Empty;
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return;
}
Body = model.Body;
UpdateProperty(nameof(Body));
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
var propName = args.PropertyName;
FetchProperty(propName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected void FetchProperty(string propertyName)
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Body):
Body = model.Body;
break;
}
UpdateProperty(propertyName);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
var model = Model.Unsafe;
if (model != null)
{
model.PropChanged -= Model_PropChanged;
}
}
}

View File

@@ -0,0 +1,266 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
{
private readonly ExtensionObject<IContentPage> _model;
[ObservableProperty]
public partial ObservableCollection<ContentViewModel> Content { get; set; } = [];
public List<CommandContextItemViewModel> Commands { get; private set; } = [];
public bool HasCommands => Commands.Count > 0;
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
public bool HasDetails => Details != null;
/////// ICommandBarContext ///////
public IEnumerable<CommandContextItemViewModel> MoreCommands => Commands.Skip(1);
public bool HasMoreCommands => Commands.Count > 1;
public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
public CommandItemViewModel? PrimaryCommand => HasCommands ? Commands[0] : null;
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? Commands[1] : null;
public List<CommandContextItemViewModel> AllCommands => Commands;
/////// /ICommandBarContext ///////
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host)
: base(model, scheduler, host)
{
_model = new(model);
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchContent();
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<ContentViewModel> newContent = [];
try
{
var newItems = _model.Unsafe!.GetContent();
foreach (var item in newItems)
{
var viewModel = ViewModelFromContent(item, PageContext);
if (viewModel != null)
{
viewModel.InitializeProperties();
newContent.Add(viewModel);
}
}
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
throw;
}
// Now, back to a UI thread to update the observable collection
DoOnUiThread(
() =>
{
ListHelpers.InPlaceUpdateList(Content, newContent);
});
}
public static ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context)
{
ContentViewModel? viewModel = content switch
{
IFormContent form => new ContentFormViewModel(form, context),
IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context),
ITreeContent tree => new ContentTreeViewModel(tree, context),
_ => null,
};
return viewModel;
}
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model == null)
{
return; // throw?
}
Commands = model.Commands
.Where(contextItem => contextItem is ICommandContextItem)
.Select(contextItem => (contextItem as ICommandContextItem)!)
.Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext))
.ToList();
Commands.ForEach(contextItem =>
{
contextItem.InitializeProperties();
});
var extensionDetails = model.Details;
if (extensionDetails != null)
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
}
UpdateDetails();
FetchContent();
model.ItemsChanged += Model_ItemsChanged;
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this));
});
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
var model = this._model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Commands):
var more = model.Commands;
if (more != null)
{
var newContextMenu = more
.Where(contextItem => contextItem is ICommandContextItem)
.Select(contextItem => (contextItem as ICommandContextItem)!)
.Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext))
.ToList();
lock (Commands)
{
ListHelpers.InPlaceUpdateList(Commands, newContextMenu);
}
Commands.ForEach(contextItem =>
{
contextItem.InitializeProperties();
});
}
else
{
Commands.Clear();
}
UpdateProperty(nameof(PrimaryCommand));
UpdateProperty(nameof(SecondaryCommand));
UpdateProperty(nameof(SecondaryCommandName));
UpdateProperty(nameof(HasCommands));
UpdateProperty(nameof(HasMoreCommands));
UpdateProperty(nameof(AllCommands));
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this));
});
break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails != null ? new(extensionDetails, PageContext) : null;
UpdateDetails();
break;
}
UpdateProperty(propertyName);
}
private void UpdateDetails()
{
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
DoOnUiThread(
() =>
{
if (HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
});
}
// InvokeItemCommand is what this will be in Xaml due to source generator
// this comes in on Enter keypresses in the SearchBox
[RelayCommand]
private void InvokePrimaryCommand(ContentPageViewModel page)
{
if (PrimaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model));
}
}
// this comes in on Ctrl+Enter keypresses in the SearchBox
[RelayCommand]
private void InvokeSecondaryCommand(ContentPageViewModel page)
{
if (SecondaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model));
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
Details?.SafeCleanup();
foreach (var item in Commands)
{
item.SafeCleanup();
}
Commands.Clear();
foreach (var item in Content)
{
item.SafeCleanup();
}
Content.Clear();
var model = _model.Unsafe;
if (model != null)
{
model.ItemsChanged -= Model_ItemsChanged;
}
}
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPageContext> context) :
ContentViewModel(context)
{
public ExtensionObject<ITreeContent> Model { get; } = new(_tree);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentViewModel? RootContent { get; protected set; }
public ObservableCollection<ContentViewModel> Children { get; } = [];
public bool HasChildren => Children.Count > 0;
// This is the content that's actually bound in XAML. We needed a
// collection, even if the collection is just a single item.
public ObservableCollection<ContentViewModel> Root => [RootContent];
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return;
}
var root = model.RootContent;
if (root != null)
{
RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext);
RootContent?.InitializeProperties();
UpdateProperty(nameof(RootContent));
UpdateProperty(nameof(Root));
}
FetchContent();
model.PropChanged += Model_PropChanged;
model.ItemsChanged += Model_ItemsChanged;
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchContent();
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
var propName = args.PropertyName;
FetchProperty(propName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected void FetchProperty(string propertyName)
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(RootContent):
var root = model.RootContent;
if (root != null)
{
RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext);
}
else
{
root = null;
}
UpdateProperty(nameof(Root));
break;
}
UpdateProperty(propertyName);
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<ContentViewModel> newContent = [];
try
{
var newItems = Model.Unsafe!.GetChildren();
foreach (var item in newItems)
{
var viewModel = ContentPageViewModel.ViewModelFromContent(item, PageContext);
if (viewModel != null)
{
viewModel.InitializeProperties();
newContent.Add(viewModel);
}
}
}
catch (Exception ex)
{
ShowException(ex);
throw;
}
// Now, back to a UI thread to update the observable collection
DoOnUiThread(
() =>
{
ListHelpers.InPlaceUpdateList(Children, newContent);
});
UpdateProperty(nameof(HasChildren));
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
RootContent?.SafeCleanup();
foreach (var item in Children)
{
item.SafeCleanup();
}
Children.Clear();
var model = Model.Unsafe;
if (model != null)
{
model.PropChanged -= Model_PropChanged;
model.ItemsChanged -= Model_ItemsChanged;
}
}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public abstract partial class ContentViewModel(WeakReference<IPageContext> context) :
ExtensionObjectViewModel(context)
{
}

View File

@@ -0,0 +1,12 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public abstract partial class DetailsDataViewModel(IPageContext context) : ExtensionObjectViewModel(context)
{
}

View File

@@ -0,0 +1,27 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public abstract partial class DetailsElementViewModel(IDetailsElement _detailsElement, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
{
private readonly ExtensionObject<IDetailsElement> _model = new(_detailsElement);
public string Key { get; private set; } = string.Empty;
public override void InitializeProperties()
{
var model = _model.Unsafe;
if (model == null)
{
return;
}
Key = model.Key ?? string.Empty;
UpdateProperty(nameof(Key));
}
}

View File

@@ -0,0 +1,46 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsLinkViewModel(
IDetailsElement _detailsElement,
WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context)
{
private readonly ExtensionObject<IDetailsLink> _dataModel =
new(_detailsElement.Data as IDetailsLink);
public string Text { get; private set; } = string.Empty;
public Uri? Link { get; private set; }
public bool IsLink => Link != null;
public bool IsText => !IsLink;
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _dataModel.Unsafe;
if (model == null)
{
return;
}
Text = model.Text ?? string.Empty;
Link = model.Link;
if (string.IsNullOrEmpty(Text) && Link != null)
{
Text = Link.ToString();
}
UpdateProperty(nameof(Text));
UpdateProperty(nameof(Link));
UpdateProperty(nameof(IsLink));
UpdateProperty(nameof(IsText));
}
}

View File

@@ -0,0 +1,21 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsSeparatorViewModel(
IDetailsElement _detailsElement,
WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context)
{
private readonly ExtensionObject<IDetailsSeparator> _dataModel =
new(_detailsElement.Data as IDetailsSeparator);
public override void InitializeProperties()
{
base.InitializeProperties();
}
}

View File

@@ -0,0 +1,42 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsTagsViewModel(
IDetailsElement _detailsElement,
WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context)
{
public List<TagViewModel> Tags { get; private set; } = [];
public bool HasTags => Tags.Count > 0;
private readonly ExtensionObject<IDetailsTags> _dataModel =
new(_detailsElement.Data as IDetailsTags);
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _dataModel.Unsafe;
if (model == null)
{
return;
}
Tags = model
.Tags?
.Select(t =>
{
var vm = new TagViewModel(t, PageContext);
vm.InitializeProperties();
return vm;
})
.ToList() ?? [];
UpdateProperty(nameof(HasTags));
UpdateProperty(nameof(Tags));
}
}

View File

@@ -0,0 +1,63 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsViewModel(IDetails _details, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
{
private readonly ExtensionObject<IDetails> _detailsModel = new(_details);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public IconInfoViewModel HeroImage { get; private set; } = new(null);
public string Title { get; private set; } = string.Empty;
public string Body { get; private set; } = string.Empty;
// Metadata is an array of IDetailsElement,
// where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator}
public List<DetailsElementViewModel> Metadata { get; private set; } = [];
public override void InitializeProperties()
{
var model = _detailsModel.Unsafe;
if (model == null)
{
return;
}
Title = model.Title ?? string.Empty;
Body = model.Body ?? string.Empty;
HeroImage = new(model.HeroImage);
HeroImage.InitializeProperties();
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Body));
UpdateProperty(nameof(HeroImage));
var meta = model.Metadata;
if (meta != null)
{
foreach (var element in meta)
{
DetailsElementViewModel? vm = element.Data switch
{
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
_ => null,
};
if (vm != null)
{
vm.InitializeProperties();
Metadata.Add(vm);
}
}
}
}
}

View File

@@ -0,0 +1,90 @@
// 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.ComponentModel;
using ManagedCommon;
namespace Microsoft.CmdPal.UI.ViewModels;
public abstract partial class ExtensionObjectViewModel : ObservableObject
{
public WeakReference<IPageContext> PageContext { get; set; }
public ExtensionObjectViewModel(IPageContext? context)
{
var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext"));
PageContext = new(realContext);
}
public ExtensionObjectViewModel(WeakReference<IPageContext> context)
{
PageContext = context;
}
public async virtual Task InitializePropertiesAsync()
{
var t = new Task(() =>
{
SafeInitializePropertiesSynchronous();
});
t.Start();
await t;
}
public void SafeInitializePropertiesSynchronous()
{
try
{
InitializeProperties();
}
catch (Exception ex)
{
ShowException(ex);
}
}
public abstract void InitializeProperties();
protected void UpdateProperty(string propertyName)
{
DoOnUiThread(() => OnPropertyChanged(propertyName));
}
protected void ShowException(Exception ex, string? extensionHint = null)
{
if (PageContext.TryGetTarget(out var pageContext))
{
pageContext.ShowException(ex, extensionHint);
}
}
protected void DoOnUiThread(Action action)
{
if (PageContext.TryGetTarget(out var pageContext))
{
Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
pageContext.Scheduler);
}
}
protected virtual void UnsafeCleanup()
{
// base doesn't do anything, but sub-classes should override this.
}
public virtual void SafeCleanup()
{
try
{
UnsafeCleanup();
}
catch (Exception ex)
{
Logger.LogDebug(ex.ToString());
}
}
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public class GlobalLogPageContext : IPageContext
{
public TaskScheduler Scheduler { get; private init; }
public void ShowException(Exception ex, string? extensionHint)
{ /*do nothing*/
}
public GlobalLogPageContext()
{
Scheduler = TaskScheduler.FromCurrentSynchronizationContext();
}
}

View File

@@ -0,0 +1,14 @@
// 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.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels;
public record HistoryItem
{
public required string CommandId { get; set; }
public required int Uses { get; set; }
}

View File

@@ -0,0 +1,45 @@
// 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.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class HotkeyManager : ObservableObject
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly List<TopLevelHotkey> _commandHotkeys;
public HotkeyManager(TopLevelCommandManager tlcManager, SettingsModel settings)
{
_topLevelCommandManager = tlcManager;
_commandHotkeys = settings.CommandHotkeys;
}
public void UpdateHotkey(string commandId, HotkeySettings? hotkey)
{
// If any of the commands were already bound to this hotkey, remove that
foreach (var item in _commandHotkeys)
{
if (item.Hotkey == hotkey)
{
item.Hotkey = null;
}
}
_commandHotkeys.RemoveAll(item => item.Hotkey == null);
foreach (var item in _commandHotkeys)
{
if (item.CommandId == commandId)
{
_commandHotkeys.Remove(item);
break;
}
}
_commandHotkeys.Add(new(hotkey, commandId));
}
}

View File

@@ -0,0 +1,45 @@
// 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.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class IconDataViewModel : ObservableObject
{
private readonly ExtensionObject<IIconData> _model = new(null);
// If the extension previously gave us a Data, then died, the data will
// throw if we actually try to read it, but the pointer itself won't be
// null, so this is relatively safe.
public bool HasIcon => !string.IsNullOrEmpty(Icon) || Data.Unsafe != null;
// Locally cached properties from IIconData.
public string Icon { get; private set; } = string.Empty;
// Streams are not trivially copy-able, so we can't copy the data locally
// first. Hence why we're sticking this into an ExtensionObject
public ExtensionObject<IRandomAccessStreamReference> Data { get; private set; } = new(null);
public IconDataViewModel(IIconData? icon)
{
_model = new(icon);
}
// Unsafe, needs to be called on BG thread
public void InitializeProperties()
{
var model = _model.Unsafe;
if (model == null)
{
return;
}
Icon = model.Icon;
Data = new(model.Data);
}
}

View File

@@ -0,0 +1,53 @@
// 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.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class IconInfoViewModel : ObservableObject
{
private readonly ExtensionObject<IIconInfo> _model = new(null);
// These are properties that are "observable" from the extension object
// itself, in the sense that they get raised by PropChanged events from the
// extension. However, we don't want to actually make them
// [ObservableProperty]s, because PropChanged comes in off the UI thread,
// and ObservableProperty is not smart enough to raise the PropertyChanged
// on the UI thread.
public IconDataViewModel Light { get; private set; }
public IconDataViewModel Dark { get; private set; }
public IconDataViewModel IconForTheme(bool light) => Light = light ? Light : Dark;
public bool HasIcon(bool light) => IconForTheme(light).HasIcon;
public bool IsSet => _model.Unsafe != null;
public IconInfoViewModel(IIconInfo? icon)
{
_model = new(icon);
Light = new(null);
Dark = new(null);
}
// Unsafe, needs to be called on BG thread
public void InitializeProperties()
{
var model = _model.Unsafe;
if (model == null)
{
return;
}
Light = new(model.Light);
Light.InitializeProperties();
Dark = new(model.Dark);
Dark.InitializeProperties();
}
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListItemViewModel(IListItem model, WeakReference<IPageContext> context)
: CommandItemViewModel(new(model), context)
{
public new ExtensionObject<IListItem> Model { get; } = new(model);
public List<TagViewModel>? Tags { get; set; }
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public bool HasTags => (Tags?.Count ?? 0) > 0;
public string TextToSuggest { get; private set; } = string.Empty;
public string Section { get; private set; } = string.Empty;
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
public bool HasDetails => Details != null;
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
// This sets IsInitialized = true
base.InitializeProperties();
var li = Model.Unsafe;
if (li == null)
{
return; // throw?
}
UpdateTags(li.Tags);
TextToSuggest = li.TextToSuggest;
Section = li.Section ?? string.Empty;
var extensionDetails = li.Details;
if (extensionDetails != null)
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
}
UpdateProperty(nameof(TextToSuggest));
UpdateProperty(nameof(Section));
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
var model = this.Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Tags):
UpdateTags(model.Tags);
break;
case nameof(TextToSuggest):
this.TextToSuggest = model.TextToSuggest ?? string.Empty;
break;
case nameof(Section):
this.Section = model.Section ?? string.Empty;
break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails != null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
break;
}
UpdateProperty(propertyName);
}
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success;
public override string ToString() => $"{Name} ListItemViewModel";
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
public override int GetHashCode() => Model.GetHashCode();
private void UpdateTags(ITag[]? newTagsFromModel)
{
DoOnUiThread(
() =>
{
var newTags = newTagsFromModel?.Select(t =>
{
var vm = new TagViewModel(t, PageContext);
vm.InitializeProperties();
return vm;
})
.ToList() ?? [];
// Tags being an ObservableCollection instead of a List lead to
// many COM exception issues.
Tags = new(newTags);
UpdateProperty(nameof(Tags));
UpdateProperty(nameof(HasTags));
});
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Tags don't have event handlers or anything to cleanup
Tags?.ForEach(t => t.SafeCleanup());
Details?.SafeCleanup();
var model = Model.Unsafe;
if (model != null)
{
// We don't need to revoke the PropChanged event handler here,
// because we are just overriding CommandItem's FetchProperty and
// piggy-backing off their PropChanged
}
}
}

View File

@@ -0,0 +1,498 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
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 ListViewModel : PageViewModel, IDisposable
{
// private readonly HashSet<ListItemViewModel> _itemCache = [];
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
// https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support
[ObservableProperty]
public partial ObservableCollection<ListItemViewModel> FilteredItems { get; set; } = [];
private ObservableCollection<ListItemViewModel> Items { get; set; } = [];
private readonly ExtensionObject<IListPage> _model;
private readonly Lock _listLock = new();
private bool _isLoading;
private bool _isFetching;
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
public bool ShowEmptyContent =>
IsInitialized &&
FilteredItems.Count == 0 &&
(!_isFetching) &&
IsLoading == false;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public bool ShowDetails { get; private set; }
private string _modelPlaceholderText = string.Empty;
public override string PlaceholderText => _modelPlaceholderText;
public string SearchText { get; private set; } = string.Empty;
public CommandItemViewModel EmptyContent { get; private set; }
private bool _isDynamic;
private Task? _initializeItemsTask;
private CancellationTokenSource? _cancellationTokenSource;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
{
base.IsInitialized = value;
UpdateEmptyContent();
}
}
public ListViewModel(IListPage model, TaskScheduler scheduler, CommandPaletteHost host)
: base(model, scheduler, host)
{
_model = new(model);
EmptyContent = new(new(null), PageContext);
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
protected override void OnFilterUpdated(string filter)
{
//// TODO: Just temp testing, need to think about where we want to filter, as ACVS in View could be done, but then grouping need CVS, maybe we do grouping in view
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
// Dynamic pages will handler their own filtering. They will tell us if
// something needs to change, by raising ItemsChanged.
if (_isDynamic)
{
// We're getting called on the UI thread.
// Hop off to a BG thread to update the extension.
_ = Task.Run(() =>
{
try
{
if (_model.Unsafe is IDynamicListPage dynamic)
{
dynamic.SearchText = filter;
}
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
});
}
else
{
// But for all normal pages, we should run our fuzzy match on them.
lock (_listLock)
{
ApplyFilterUnderLock();
}
ItemsUpdated?.Invoke(this, EventArgs.Empty);
UpdateEmptyContent();
_isLoading = false;
}
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems()
{
// TEMPORARY: just plop all the items into a single group
// see 9806fe5d8 for the last commit that had this with sections
_isFetching = true;
try
{
IListItem[] newItems = _model.Unsafe!.GetItems();
// Collect all the items into new viewmodels
Collection<ListItemViewModel> newViewModels = [];
// TODO we can probably further optimize this by also keeping a
// HashSet of every ExtensionObject we currently have, and only
// building new viewmodels for the ones we haven't already built.
foreach (IListItem? item in newItems)
{
ListItemViewModel viewModel = new(item, new(this));
// If an item fails to load, silently ignore it.
if (viewModel.SafeFastInit())
{
newViewModels.Add(viewModel);
}
}
IEnumerable<ListItemViewModel> firstTwenty = newViewModels.Take(20);
foreach (ListItemViewModel? item in firstTwenty)
{
item?.SafeInitializeProperties();
}
// Cancel any ongoing search
if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
}
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels);
}
// TODO: Iterate over everything in Items, and prune items from the
// cache if we don't need them anymore
}
catch (Exception ex)
{
// TODO: Move this within the for loop, so we can catch issues with individual items
// Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently.
ShowException(ex, _model?.Unsafe?.Name);
throw;
}
finally
{
_isFetching = false;
}
_cancellationTokenSource = new CancellationTokenSource();
_initializeItemsTask = new Task(() =>
{
try
{
InitializeItemsTask(_cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
}
});
_initializeItemsTask.Start();
DoOnUiThread(
() =>
{
lock (_listLock)
{
// Now that our Items contains everything we want, it's time for us to
// re-evaluate our Filter on those items.
if (!_isDynamic)
{
// A static list? Great! Just run the filter.
ApplyFilterUnderLock();
}
else
{
// A dynamic list? Even better! Just stick everything into
// FilteredItems. The extension already did any filtering it cared about.
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
}
UpdateEmptyContent();
}
ItemsUpdated?.Invoke(this, EventArgs.Empty);
_isLoading = false;
});
}
private void InitializeItemsTask(CancellationToken ct)
{
// Were we already canceled?
ct.ThrowIfCancellationRequested();
ListItemViewModel[] iterable;
lock (_listLock)
{
iterable = Items.ToArray();
}
foreach (ListItemViewModel item in iterable)
{
ct.ThrowIfCancellationRequested();
// TODO: GH #502
// We should probably remove the item from the list if it
// entered the error state. I had issues doing that without having
// multiple threads muck with `Items` (and possibly FilteredItems!)
// at once.
item.SafeInitializeProperties();
ct.ThrowIfCancellationRequested();
}
}
/// <summary>
/// Apply our current filter text to the list of items, and update
/// FilteredItems to match the results.
/// </summary>
private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter));
/// <summary>
/// Helper to generate a weighting for a given list item, based on title,
/// subtitle, etc. Largely a copy of the version in ListHelpers, but
/// operating on ViewModels instead of extension objects.
/// </summary>
private static int ScoreListItem(string query, CommandItemViewModel listItem)
{
if (string.IsNullOrEmpty(query))
{
return 1;
}
MatchResult nameMatch = StringMatcher.FuzzySearch(query, listItem.Title);
MatchResult descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle);
return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max();
}
private struct ScoredListItemViewModel
{
public int Score;
public ListItemViewModel ViewModel;
}
// Similarly stolen from ListHelpers.FilterList
public static IEnumerable<ListItemViewModel> FilterList(IEnumerable<ListItemViewModel> items, string query)
{
IOrderedEnumerable<ScoredListItemViewModel> scores = items
.Where(i => !i.IsInErrorState)
.Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) })
.Where(score => score.Score > 0)
.OrderByDescending(score => score.Score);
return scores
.Select(score => score.ViewModel);
}
// InvokeItemCommand is what this will be in Xaml due to source generator
// This is what gets invoked when the user presses <enter>
[RelayCommand]
private void InvokeItem(ListItemViewModel? item)
{
if (item != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model));
}
else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
EmptyContent.PrimaryCommand.Command.Model,
EmptyContent.PrimaryCommand.Model));
}
}
// This is what gets invoked when the user presses <ctrl+enter>
[RelayCommand]
private void InvokeSecondaryCommand(ListItemViewModel? item)
{
if (item != null)
{
if (item.SecondaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command.Model, item.Model));
}
}
else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
EmptyContent.SecondaryCommand.Command.Model,
EmptyContent.SecondaryCommand.Model));
}
}
[RelayCommand]
private void UpdateSelectedItem(ListItemViewModel item)
{
if (!item.SafeSlowInit())
{
return;
}
// GH #322:
// For inexplicable reasons, if you try updating the command bar and
// the details on the same UI thread tick as updating the list, we'll
// explode
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
if (ShowDetails && item.HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
TextToSuggest = item.TextToSuggest;
});
}
public override void InitializeProperties()
{
base.InitializeProperties();
IListPage? model = _model.Unsafe;
if (model == null)
{
return; // throw?
}
_isDynamic = model is IDynamicListPage;
ShowDetails = model.ShowDetails;
UpdateProperty(nameof(ShowDetails));
_modelPlaceholderText = model.PlaceholderText;
UpdateProperty(nameof(PlaceholderText));
SearchText = model.SearchText;
UpdateProperty(nameof(SearchText));
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.SlowInitializeProperties();
FetchItems();
model.ItemsChanged += Model_ItemsChanged;
}
public void LoadMoreIfNeeded()
{
IListPage? model = this._model.Unsafe;
if (model == null)
{
return;
}
if (model.HasMoreItems && !_isLoading)
{
_isLoading = true;
_ = Task.Run(() =>
{
try
{
model.LoadMore();
}
catch (Exception ex)
{
ShowException(ex, model.Name);
}
});
}
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
IListPage? model = this._model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(ShowDetails):
this.ShowDetails = model.ShowDetails;
break;
case nameof(PlaceholderText):
this._modelPlaceholderText = model.PlaceholderText;
break;
case nameof(SearchText):
this.SearchText = model.SearchText;
break;
case nameof(EmptyContent):
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.InitializeProperties();
break;
case nameof(IsLoading):
UpdateEmptyContent();
break;
}
UpdateProperty(propertyName);
}
private void UpdateEmptyContent()
{
UpdateProperty(nameof(ShowEmptyContent));
if (!ShowEmptyContent || EmptyContent.Model.Unsafe == null)
{
return;
}
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(EmptyContent));
});
}
public void Dispose()
{
GC.SuppressFinalize(this);
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
EmptyContent?.SafeCleanup();
EmptyContent = new(new(null), PageContext); // necessary?
_cancellationTokenSource?.Cancel();
lock (_listLock)
{
foreach (ListItemViewModel item in Items)
{
item.SafeCleanup();
}
Items.Clear();
foreach (ListItemViewModel item in FilteredItems)
{
item.SafeCleanup();
}
FilteredItems.Clear();
}
IListPage? model = _model.Unsafe;
if (model != null)
{
model.ItemsChanged -= Model_ItemsChanged;
}
}
}

View File

@@ -0,0 +1,17 @@
// 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.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class LoadingPageViewModel : PageViewModel
{
public LoadingPageViewModel(IPage? model, TaskScheduler scheduler)
: base(model, scheduler, CommandPaletteHost.Instance)
{
ModelIsLoading = true;
IsInitialized = false;
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class LogMessageViewModel : ExtensionObjectViewModel
{
private readonly ExtensionObject<ILogMessage> _model;
public string Message { get; private set; } = string.Empty;
public string ExtensionPfn { get; set; } = string.Empty;
public LogMessageViewModel(ILogMessage message, IPageContext context)
: base(context)
{
_model = new(message);
}
public override void InitializeProperties()
{
var model = _model.Unsafe;
if (model == null)
{
return; // throw?
}
Message = model.Message;
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to perform a list item's secondary command when the user presses ctrl+enter in the search box
/// </summary>
public record ActivateSecondaryCommandMessage
{
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to perform a list item's command when the user presses enter in the search box
/// </summary>
public record ActivateSelectedListItemMessage
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ClearSearchMessage()
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record DismissMessage()
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record FocusSearchBoxMessage()
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record GoHomeMessage()
{
}

View File

@@ -0,0 +1,12 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record HandleCommandResultMessage(ExtensionObject<ICommandResult> Result)
{
}

View File

@@ -0,0 +1,12 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record HideDetailsMessage()
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record HotkeySummonMessage(string CommandId, IntPtr Hwnd)
{
}

View File

@@ -0,0 +1,12 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record LaunchUriMessage(Uri Uri)
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record NavigateBackMessage(bool FromBackspace = false)
{
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to navigate to the next command in the page when pressing the Down key in the SearchBox.
/// </summary>
public record NavigateNextCommand
{
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to navigate to the previous command in the page when pressing the Down key in the SearchBox.
/// </summary>
public record NavigatePreviousCommand
{
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to perform a list item's secondary command when the user presses ctrl+enter in the search box
/// </summary>
public record OpenContextMenuMessage
{
}

View File

@@ -0,0 +1,11 @@
// 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.BuiltinCommands;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record OpenSettingsMessage()
{
}

View File

@@ -0,0 +1,56 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to do a command - navigate to a page or invoke it
/// </summary>
public record PerformCommandMessage
{
public ExtensionObject<ICommand> Command { get; }
public object? Context { get; }
public bool WithAnimation { get; set; } = true;
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;
Context = null;
}
public PerformCommandMessage(TopLevelCommandItemWrapper topLevelCommand)
{
Command = new(topLevelCommand.Command);
Context = null;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context)
{
Command = command;
Context = context.Unsafe;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<ICommandItem> context)
{
Command = command;
Context = context.Unsafe;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<ICommandContextItem> context)
{
Command = command;
Context = context.Unsafe;
}
public PerformCommandMessage(ConfirmResultViewModel vm)
{
Command = vm.PrimaryCommand.Model;
Context = null;
}
}

View File

@@ -0,0 +1,14 @@
// 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.BuiltinCommands;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Message which closes the application. Used by <see cref="QuitCommand"/> via <see cref="BuiltInsCommandProvider"/>.
/// </summary>
public record QuitMessage()
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ReloadCommandsMessage()
{
}

View File

@@ -0,0 +1,12 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ShowDetailsMessage(DetailsViewModel Details)
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ShowWindowMessage(IntPtr Hwnd)
{
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to update the command bar at the bottom to reflect the commands for a list item
/// </summary>
public record UpdateCommandBarMessage(ICommandBarContext? ViewModel)
{
}
// Represents everything the command bar needs to know about to show command
// buttons at the bottom.
//
// This is implemented by both ListItemViewModel and ContentPageViewModel,
// the two things with sub-commands.
public interface ICommandBarContext : INotifyPropertyChanged
{
public IEnumerable<CommandContextItemViewModel> MoreCommands { get; }
public bool HasMoreCommands { get; }
public string SecondaryCommandName { get; }
public CommandItemViewModel? PrimaryCommand { get; }
public CommandItemViewModel? SecondaryCommand { get; }
public List<CommandContextItemViewModel> AllCommands { get; }
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record UpdateFallbackItemsMessage()
{
}

View File

@@ -0,0 +1,58 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<!-- For MVVM Toolkit Partial Properties/AOT support -->
<LangVersion>preview</LangVersion>
<!-- Disable SA1313 for Primary Constructor fields conflict https://learn.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/instance-constructors#primary-constructors -->
<NoWarn>SA1313;</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Common" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="AdaptiveCards.Templating" />
<PackageReference Include="AdaptiveCards.ObjectModel.WinUI3" GeneratePathProperty="true" />
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="WyHash" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="Assets\template.zip" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Models;
public class ExtensionObject<T>(T? value) // where T : IInspectable
{
public T? Unsafe { get; } = value;
public override bool Equals(object? obj) => obj is ExtensionObject<T> ext && ext.Unsafe?.Equals(this.Unsafe) == true;
public override int GetHashCode() => Unsafe?.GetHashCode() ?? base.GetHashCode();
}

View File

@@ -0,0 +1,414 @@
// 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.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
using Windows.ApplicationModel.AppExtensions;
using Windows.Foundation;
using Windows.Foundation.Collections;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
public class ExtensionService : IExtensionService, IDisposable
{
public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded;
public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved;
private static readonly PackageCatalog _catalog = PackageCatalog.OpenForCurrentUser();
private static readonly Lock _lock = new();
private readonly SemaphoreSlim _getInstalledExtensionsLock = new(1, 1);
private readonly SemaphoreSlim _getInstalledWidgetsLock = new(1, 1);
// private readonly ILocalSettingsService _localSettingsService;
private bool _disposedValue;
private const string CreateInstanceProperty = "CreateInstance";
private const string ClassIdProperty = "@ClassId";
private static readonly List<IExtensionWrapper> _installedExtensions = [];
private static readonly List<IExtensionWrapper> _enabledExtensions = [];
public ExtensionService()
{
_catalog.PackageInstalling += Catalog_PackageInstalling;
_catalog.PackageUninstalling += Catalog_PackageUninstalling;
_catalog.PackageUpdating += Catalog_PackageUpdating;
//// These two were an investigation into getting updates when a package
//// gets redeployed from VS. Neither get raised (nor do the above)
//// _catalog.PackageStatusChanged += Catalog_PackageStatusChanged;
//// _catalog.PackageStaging += Catalog_PackageStaging;
// _localSettingsService = settingsService;
}
private void Catalog_PackageInstalling(PackageCatalog sender, PackageInstallingEventArgs args)
{
if (args.IsComplete)
{
lock (_lock)
{
InstallPackageUnderLock(args.Package);
}
}
}
private void Catalog_PackageUninstalling(PackageCatalog sender, PackageUninstallingEventArgs args)
{
if (args.IsComplete)
{
lock (_lock)
{
UninstallPackageUnderLock(args.Package);
}
}
}
private void Catalog_PackageUpdating(PackageCatalog sender, PackageUpdatingEventArgs args)
{
if (args.IsComplete)
{
lock (_lock)
{
// Get any extension providers that we previously had from this app
UninstallPackageUnderLock(args.TargetPackage);
// then add the new ones.
InstallPackageUnderLock(args.TargetPackage);
}
}
}
private void InstallPackageUnderLock(Package package)
{
var isCmdPalExtensionResult = Task.Run(() =>
{
return IsValidCmdPalExtension(package);
}).Result;
var isExtension = isCmdPalExtensionResult.IsExtension;
var extension = isCmdPalExtensionResult.Extension;
if (isExtension && extension != null)
{
CommandPaletteHost.Instance.DebugLog($"Installed new extension app {extension.DisplayName}");
Task.Run(async () =>
{
await _getInstalledExtensionsLock.WaitAsync();
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
OnExtensionAdded?.Invoke(this, wrappers);
}
finally
{
_getInstalledExtensionsLock.Release();
}
});
}
}
private void UninstallPackageUnderLock(Package package)
{
List<IExtensionWrapper> removedExtensions = [];
foreach (var extension in _installedExtensions)
{
if (extension.PackageFullName == package.Id.FullName)
{
CommandPaletteHost.Instance.DebugLog($"Uninstalled extension app {extension.PackageDisplayName}");
removedExtensions.Add(extension);
}
}
Task.Run(async () =>
{
await _getInstalledExtensionsLock.WaitAsync();
try
{
_installedExtensions.RemoveAll(i => removedExtensions.Contains(i));
OnExtensionRemoved?.Invoke(this, removedExtensions);
}
finally
{
_getInstalledExtensionsLock.Release();
}
});
}
private static async Task<IsExtensionResult> IsValidCmdPalExtension(Package package)
{
var extensions = await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
foreach (var extension in extensions)
{
if (package.Id?.FullName == extension.Package?.Id?.FullName)
{
var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension);
return new(cmdPalProvider != null && classId.Count != 0, extension);
}
}
return new(false, null);
}
private static async Task<(IPropertySet? CmdPalProvider, List<string> ClassIds)> GetCmdPalExtensionPropertiesAsync(AppExtension extension)
{
var classIds = new List<string>();
var properties = await extension.GetExtensionPropertiesAsync();
if (properties is null)
{
return (null, classIds);
}
var cmdPalProvider = GetSubPropertySet(properties, "CmdPalProvider");
if (cmdPalProvider is null)
{
return (null, classIds);
}
var activation = GetSubPropertySet(cmdPalProvider, "Activation");
if (activation is null)
{
return (cmdPalProvider, classIds);
}
// Handle case where extension creates multiple instances.
classIds.AddRange(GetCreateInstanceList(activation));
return (cmdPalProvider, classIds);
}
private static async Task<IEnumerable<AppExtension>> GetInstalledAppExtensionsAsync() => await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
public async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
await _getInstalledExtensionsLock.WaitAsync();
try
{
if (_installedExtensions.Count == 0)
{
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
}
return includeDisabledExtensions ? _installedExtensions : _enabledExtensions;
}
finally
{
_getInstalledExtensionsLock.Release();
}
}
private static void UpdateExtensionsListsFromWrappers(List<ExtensionWrapper> wrappers)
{
foreach (var extensionWrapper in wrappers)
{
// var localSettingsService = Application.Current.GetService<ILocalSettingsService>();
var extensionUniqueId = extensionWrapper.ExtensionUniqueId;
var isExtensionDisabled = false; // await localSettingsService.ReadSettingAsync<bool>(extensionUniqueId + "-ExtensionDisabled");
_installedExtensions.Add(extensionWrapper);
if (!isExtensionDisabled)
{
_enabledExtensions.Add(extensionWrapper);
}
// TelemetryFactory.Get<ITelemetry>().Log(
// "Extension_ReportInstalled",
// LogLevel.Critical,
// new ReportInstalledExtensionEvent(extensionUniqueId, isEnabled: !isExtensionDisabled));
}
}
private static async Task<List<ExtensionWrapper>> CreateWrappersForExtension(AppExtension extension)
{
var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension);
if (cmdPalProvider == null || classIds.Count == 0)
{
return [];
}
List<ExtensionWrapper> wrappers = [];
foreach (var classId in classIds)
{
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
return wrappers;
}
private static ExtensionWrapper CreateExtensionWrapper(AppExtension extension, IPropertySet cmdPalProvider, string classId)
{
var extensionWrapper = new ExtensionWrapper(extension, classId);
var supportedInterfaces = GetSubPropertySet(cmdPalProvider, "SupportedInterfaces");
if (supportedInterfaces is not null)
{
foreach (var supportedInterface in supportedInterfaces)
{
ProviderType pt;
if (Enum.TryParse(supportedInterface.Key, out pt))
{
extensionWrapper.AddProviderType(pt);
}
else
{
// log warning that extension declared unsupported extension interface
CommandPaletteHost.Instance.DebugLog($"Extension {extension.DisplayName} declared an unsupported interface: {supportedInterface.Key}");
}
}
}
return extensionWrapper;
}
public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId)
{
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
return extension.FirstOrDefault();
}
public async Task SignalStopExtensionsAsync()
{
var installedExtensions = await GetInstalledExtensionsAsync();
foreach (var installedExtension in installedExtensions)
{
if (installedExtension.IsRunning())
{
installedExtension.SignalDispose();
}
}
}
public async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(ProviderType providerType, bool includeDisabledExtensions = false)
{
var installedExtensions = await GetInstalledExtensionsAsync(includeDisabledExtensions);
List<IExtensionWrapper> filteredExtensions = [];
foreach (var installedExtension in installedExtensions)
{
if (installedExtension.HasProviderType(providerType))
{
filteredExtensions.Add(installedExtension);
}
}
return filteredExtensions;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_getInstalledExtensionsLock.Dispose();
_getInstalledWidgetsLock.Dispose();
}
_disposedValue = true;
}
}
private static IPropertySet? GetSubPropertySet(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as IPropertySet : null;
private static object[]? GetSubPropertySetArray(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as object[] : null;
/// <summary>
/// There are cases where the extension creates multiple COM instances.
/// </summary>
/// <param name="activationPropSet">Activation property set object</param>
/// <returns>List of ClassId strings associated with the activation property</returns>
private static List<string> GetCreateInstanceList(IPropertySet activationPropSet)
{
var propSetList = new List<string>();
var singlePropertySet = GetSubPropertySet(activationPropSet, CreateInstanceProperty);
if (singlePropertySet != null)
{
var classId = GetProperty(singlePropertySet, ClassIdProperty);
// If the instance has a classId as a single string, then it's only supporting a single instance.
if (classId != null)
{
propSetList.Add(classId);
}
}
else
{
var propertySetArray = GetSubPropertySetArray(activationPropSet, CreateInstanceProperty);
if (propertySetArray != null)
{
foreach (var prop in propertySetArray)
{
if (prop is not IPropertySet propertySet)
{
continue;
}
var classId = GetProperty(propertySet, ClassIdProperty);
if (classId != null)
{
propSetList.Add(classId);
}
}
}
}
return propSetList;
}
private static string? GetProperty(IPropertySet propSet, string name) => propSet[name] as string;
public void EnableExtension(string extensionUniqueId)
{
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Add(extension.First());
}
public void DisableExtension(string extensionUniqueId)
{
var extension = _enabledExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Remove(extension.First());
}
/*
///// <inheritdoc cref="IExtensionService.DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper)"/>
//public async Task<bool> DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper extension)
//{
// // Only attempt to disable feature if its available.
// if (IsWindowsOptionalFeatureAvailableForExtension(extension.ExtensionClassId))
// {
// return false;
// }
// _log.Warning($"Disabling extension: '{extension.ExtensionDisplayName}' because its feature is absent or unknown");
// // Remove extension from list of enabled extensions to prevent Dev Home from re-querying for this extension
// // for the rest of its process lifetime.
// DisableExtension(extension.ExtensionUniqueId);
// // Update the local settings so the next time the user launches Dev Home the extension will be disabled.
// await _localSettingsService.SaveSettingAsync(extension.ExtensionUniqueId + "-ExtensionDisabled", true);
// return true;
//} */
}
internal record struct IsExtensionResult(bool IsExtension, AppExtension? Extension)
{
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
using Windows.ApplicationModel.AppExtensions;
using Windows.Win32;
using Windows.Win32.System.Com;
using WinRT;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
public class ExtensionWrapper : IExtensionWrapper
{
private const int HResultRpcServerNotRunning = -2147023174;
private readonly string _appUserModelId;
private readonly string _extensionId;
private readonly Lock _lock = new();
private readonly List<ProviderType> _providerTypes = [];
private readonly Dictionary<Type, ProviderType> _providerTypeMap = new()
{
[typeof(ICommandProvider)] = ProviderType.Commands,
};
private IExtension? _extensionObject;
public ExtensionWrapper(AppExtension appExtension, string classId)
{
PackageDisplayName = appExtension.Package.DisplayName;
ExtensionDisplayName = appExtension.DisplayName;
PackageFullName = appExtension.Package.Id.FullName;
PackageFamilyName = appExtension.Package.Id.FamilyName;
ExtensionClassId = classId ?? throw new ArgumentNullException(nameof(classId));
Publisher = appExtension.Package.PublisherDisplayName;
InstalledDate = appExtension.Package.InstalledDate;
Version = appExtension.Package.Id.Version;
_appUserModelId = appExtension.AppInfo.AppUserModelId;
_extensionId = appExtension.Id;
}
public string PackageDisplayName { get; }
public string ExtensionDisplayName { get; }
public string PackageFullName { get; }
public string PackageFamilyName { get; }
public string ExtensionClassId { get; }
public string Publisher { get; }
public DateTimeOffset InstalledDate { get; }
public PackageVersion Version { get; }
/// <summary>
/// Gets the unique id for this Dev Home extension. The unique id is a concatenation of:
/// <list type="number">
/// <item>The AppUserModelId (AUMID) of the extension's application. The AUMID is the concatenation of the package
/// family name and the application id and uniquely identifies the application containing the extension within
/// the package.</item>
/// <item>The Extension Id. This is the unique identifier of the extension within the application.</item>
/// </list>
/// </summary>
public string ExtensionUniqueId => _appUserModelId + "!" + _extensionId;
public bool IsRunning()
{
if (_extensionObject is null)
{
return false;
}
try
{
_extensionObject.As<IInspectable>().GetRuntimeClassName();
}
catch (COMException e)
{
if (e.ErrorCode == HResultRpcServerNotRunning)
{
return false;
}
throw;
}
return true;
}
public async Task StartExtensionAsync()
{
await Task.Run(() =>
{
lock (_lock)
{
if (!IsRunning())
{
Logger.LogDebug($"Starting {ExtensionDisplayName} ({ExtensionClassId})");
nint 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);
if (hr.Value == -2147024893)
{
Logger.LogDebug($"Failed to find {ExtensionDisplayName}: {hr}. It may have been uninstalled or deleted.");
// We don't really need to throw this exception.
// We'll just return out nothing.
return;
}
extensionPtr = Marshal.GetIUnknownForObject(extensionObj);
if (hr < 0)
{
Logger.LogDebug($"Failed to instantiate {ExtensionDisplayName}: {hr}");
Marshal.ThrowExceptionForHR(hr);
}
_extensionObject = MarshalInterface<IExtension>.FromAbi(extensionPtr);
}
finally
{
if (extensionPtr != nint.Zero)
{
Marshal.Release(extensionPtr);
}
}
}
}
});
}
public void SignalDispose()
{
lock (_lock)
{
if (IsRunning())
{
_extensionObject?.Dispose();
}
_extensionObject = null;
}
}
public IExtension? GetExtensionObject()
{
lock (_lock)
{
return IsRunning() ? _extensionObject : null;
}
}
public async Task<T?> GetProviderAsync<T>()
where T : class
{
await StartExtensionAsync();
return GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]) as T;
}
public async Task<IEnumerable<T>> GetListOfProvidersAsync<T>()
where T : class
{
await StartExtensionAsync();
object? supportedProviders = GetExtensionObject()?.GetProvider(_providerTypeMap[typeof(T)]);
if (supportedProviders is IEnumerable<T> multipleProvidersSupported)
{
return multipleProvidersSupported;
}
else if (supportedProviders is T singleProviderSupported)
{
return [singleProviderSupported];
}
return Enumerable.Empty<T>();
}
public void AddProviderType(ProviderType providerType) => _providerTypes.Add(providerType);
public bool HasProviderType(ProviderType providerType) => _providerTypes.Contains(providerType);
}

View File

@@ -0,0 +1,19 @@
GetPhysicallyInstalledSystemMemory
GlobalMemoryStatusEx
GetSystemInfo
CoCreateInstance
SetForegroundWindow
IsIconic
RegisterHotKey
SetWindowLongPtr
CallWindowProc
ShowWindow
SetForegroundWindow
SetFocus
SetActiveWindow
MonitorFromWindow
GetMonitorInfo
SHCreateStreamOnFileEx
CoAllowSetForegroundWindow
SHCreateStreamOnFileEx
SHLoadIndirectString

View File

@@ -0,0 +1,242 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
{
public TaskScheduler Scheduler { get; private set; }
private readonly ExtensionObject<IPage> _pageModel;
public bool IsLoading => ModelIsLoading || (!IsInitialized);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLoading))]
public virtual partial bool IsInitialized { get; protected set; }
[ObservableProperty]
public partial string ErrorMessage { get; protected set; } = string.Empty;
[ObservableProperty]
public partial bool IsNested { get; set; } = true;
// This is set from the SearchBar
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSuggestion))]
public partial string Filter { get; set; } = string.Empty;
[ObservableProperty]
public virtual partial string PlaceholderText { get; private set; } = "Type here to search...";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSuggestion))]
public virtual partial string TextToSuggest { get; protected set; } = string.Empty;
public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter;
[ObservableProperty]
public partial CommandPaletteHost ExtensionHost { get; private set; }
public bool HasStatusMessage => MostRecentStatusMessage != null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasStatusMessage))]
public partial StatusMessageViewModel? MostRecentStatusMessage { get; private set; } = null;
public ObservableCollection<StatusMessageViewModel> StatusMessages => ExtensionHost.StatusMessages;
// These are properties that are "observable" from the extension object
// itself, in the sense that they get raised by PropChanged events from the
// extension. However, we don't want to actually make them
// [ObservableProperty]s, because PropChanged comes in off the UI thread,
// and ObservableProperty is not smart enough to raise the PropertyChanged
// on the UI thread.
public string Name { get; protected set; } = string.Empty;
public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty;
// This property maps to `IPage.IsLoading`, but we want to expose our own
// `IsLoading` property as a combo of this value and `IsInitialized`
public bool ModelIsLoading { get; protected set; } = true;
public IconInfoViewModel Icon { get; protected set; }
public PageViewModel(IPage? model, TaskScheduler scheduler, CommandPaletteHost extensionHost)
: base((IPageContext?)null)
{
_pageModel = new(model);
Scheduler = scheduler;
PageContext = new(this);
ExtensionHost = extensionHost;
Icon = new(null);
ExtensionHost.StatusMessages.CollectionChanged += StatusMessages_CollectionChanged;
UpdateHasStatusMessage();
}
private void StatusMessages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => UpdateHasStatusMessage();
private void UpdateHasStatusMessage()
{
if (ExtensionHost.StatusMessages.Any())
{
var last = ExtensionHost.StatusMessages.Last();
MostRecentStatusMessage = last;
}
else
{
MostRecentStatusMessage = null;
}
}
//// Run on background thread from ListPage.xaml.cs
[RelayCommand]
private Task<bool> InitializeAsync()
{
// TODO: We may want a SemaphoreSlim lock here.
// TODO: We may want to investigate using some sort of AsyncEnumerable or populating these as they come into the UI layer
// Though we have to think about threading here and circling back to the UI thread with a TaskScheduler.
try
{
InitializeProperties();
}
catch (Exception ex)
{
ShowException(ex, _pageModel?.Unsafe?.Name);
return Task.FromResult(false);
}
// Notify we're done back on the UI Thread.
Task.Factory.StartNew(
() =>
{
IsInitialized = true;
// TODO: Do we want an event/signal here that the Page Views can listen to? (i.e. ListPage setting the selected index to 0, however, in async world the user may have already started navigating around page...)
},
CancellationToken.None,
TaskCreationOptions.None,
Scheduler);
return Task.FromResult(true);
}
public override void InitializeProperties()
{
var page = _pageModel.Unsafe;
if (page == null)
{
return; // throw?
}
Name = page.Name;
ModelIsLoading = page.IsLoading;
Title = page.Title;
Icon = new(page.Icon);
Icon.InitializeProperties();
// Let the UI know about our initial properties too.
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(ModelIsLoading));
UpdateProperty(nameof(IsLoading));
UpdateProperty(nameof(Icon));
page.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
var propName = args.PropertyName;
FetchProperty(propName);
}
catch (Exception ex)
{
ShowException(ex, _pageModel?.Unsafe?.Name);
}
}
partial void OnFilterChanged(string oldValue, string newValue) => OnFilterUpdated(newValue);
protected virtual void OnFilterUpdated(string filter)
{
// The base page has no notion of data, so we do nothing here...
// subclasses should override.
}
protected virtual void FetchProperty(string propertyName)
{
var model = this._pageModel.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Name):
this.Name = model.Name ?? string.Empty;
UpdateProperty(nameof(Title));
break;
case nameof(Title):
this.Title = model.Title ?? string.Empty;
break;
case nameof(IsLoading):
this.ModelIsLoading = model.IsLoading;
UpdateProperty(nameof(ModelIsLoading));
break;
case nameof(Icon):
this.Icon = new(model.Icon);
break;
}
UpdateProperty(propertyName);
}
public new void ShowException(Exception ex, string? extensionHint = null)
{
// Set the extensionHint to the Page Title (if we have one, and one not provided).
// extensionHint ??= _pageModel?.Unsafe?.Title;
extensionHint ??= ExtensionHost.Extension?.ExtensionDisplayName ?? Title;
Task.Factory.StartNew(
() =>
{
ErrorMessage += $"A bug occurred in {$"the \"{extensionHint}\"" ?? "an unknown's"} extension's code:\n{ex.Message}\n{ex.Source}\n{ex.StackTrace}\n\n";
},
CancellationToken.None,
TaskCreationOptions.None,
Scheduler);
}
public override string ToString() => $"{Title} ViewModel";
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
ExtensionHost.StatusMessages.CollectionChanged -= StatusMessages_CollectionChanged;
var model = _pageModel.Unsafe;
if (model != null)
{
model.PropChanged -= Model_PropChanged;
}
}
}
public interface IPageContext
{
public void ShowException(Exception ex, string? extensionHint = null);
public TaskScheduler Scheduler { get; }
}

View File

@@ -0,0 +1,70 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ProgressViewModel : ExtensionObjectViewModel
{
public ExtensionObject<IProgressState> Model { get; }
public bool IsIndeterminate { get; private set; }
public uint ProgressPercent { get; private set; }
public ProgressViewModel(IProgressState progress, WeakReference<IPageContext> context)
: base(context)
{
Model = new(progress);
}
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
IsIndeterminate = model.IsIndeterminate;
ProgressPercent = model.ProgressPercent;
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this.Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(IsIndeterminate):
this.IsIndeterminate = model.IsIndeterminate;
break;
case nameof(ProgressPercent):
this.ProgressPercent = model.ProgressPercent;
break;
}
UpdateProperty(propertyName);
}
}

View File

@@ -0,0 +1,414 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Microsoft.CmdPal.UI.ViewModels.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.UI.ViewModels.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Create another.
/// </summary>
public static string builtin_create_extension_create_another {
get {
return ResourceManager.GetString("builtin_create_extension_create_another", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Where should the new extension be created? This path will be created if it doesn&apos;t exist.
/// </summary>
public static string builtin_create_extension_directory_description {
get {
return ResourceManager.GetString("builtin_create_extension_directory_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output path.
/// </summary>
public static string builtin_create_extension_directory_header {
get {
return ResourceManager.GetString("builtin_create_extension_directory_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output path.
/// </summary>
public static string builtin_create_extension_directory_label {
get {
return ResourceManager.GetString("builtin_create_extension_directory_label", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output path is required.
/// </summary>
public static string builtin_create_extension_directory_required {
get {
return ResourceManager.GetString("builtin_create_extension_directory_required", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The name of your extension as users will see it..
/// </summary>
public static string builtin_create_extension_display_name_description {
get {
return ResourceManager.GetString("builtin_create_extension_display_name_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Display name.
/// </summary>
public static string builtin_create_extension_display_name_header {
get {
return ResourceManager.GetString("builtin_create_extension_display_name_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Display name.
/// </summary>
public static string builtin_create_extension_display_name_label {
get {
return ResourceManager.GetString("builtin_create_extension_display_name_label", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Display name is required.
/// </summary>
public static string builtin_create_extension_display_name_required {
get {
return ResourceManager.GetString("builtin_create_extension_display_name_required", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open.
/// </summary>
public static string builtin_create_extension_name {
get {
return ResourceManager.GetString("builtin_create_extension_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This is the name of your new extension project. It should be a valid C# class name. Best practice is to also include the word &apos;Extension&apos; in the name..
/// </summary>
public static string builtin_create_extension_name_description {
get {
return ResourceManager.GetString("builtin_create_extension_name_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Extension name.
/// </summary>
public static string builtin_create_extension_name_header {
get {
return ResourceManager.GetString("builtin_create_extension_name_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Extension name.
/// </summary>
public static string builtin_create_extension_name_label {
get {
return ResourceManager.GetString("builtin_create_extension_name_label", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Extension name is required, without spaces.
/// </summary>
public static string builtin_create_extension_name_required {
get {
return ResourceManager.GetString("builtin_create_extension_name_required", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open directory.
/// </summary>
public static string builtin_create_extension_open_directory {
get {
return ResourceManager.GetString("builtin_create_extension_open_directory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open Solution.
/// </summary>
public static string builtin_create_extension_open_solution {
get {
return ResourceManager.GetString("builtin_create_extension_open_solution", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Use this page to create a new extension project..
/// </summary>
public static string builtin_create_extension_page_text {
get {
return ResourceManager.GetString("builtin_create_extension_page_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create your new extension.
/// </summary>
public static string builtin_create_extension_page_title {
get {
return ResourceManager.GetString("builtin_create_extension_page_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create extension.
/// </summary>
public static string builtin_create_extension_submit {
get {
return ResourceManager.GetString("builtin_create_extension_submit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Successfully created your new extension!.
/// </summary>
public static string builtin_create_extension_success {
get {
return ResourceManager.GetString("builtin_create_extension_success", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create a new extension.
/// </summary>
public static string builtin_create_extension_title {
get {
return ResourceManager.GetString("builtin_create_extension_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Your new extension &apos;${displayName}&apos; was created in:.
/// </summary>
public static string builtin_created_in_text {
get {
return ResourceManager.GetString("builtin_created_in_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Now that your extension project has been created, open the solution up in Visual Studio to start writing your extension code..
/// </summary>
public static string builtin_created_next_steps {
get {
return ResourceManager.GetString("builtin_created_next_steps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Navigate to `${name}Page.cs` to start adding items to the list, or to `${name}CommandsProvider.cs` to add new commands..
/// </summary>
public static string builtin_created_next_steps_p2 {
get {
return ResourceManager.GetString("builtin_created_next_steps_p2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Once you&apos;re ready to test deploy the package locally with Visual Studio, then run the \&quot;Reload\&quot; command in the Command Palette to load your new extension..
/// </summary>
public static string builtin_created_next_steps_p3 {
get {
return ResourceManager.GetString("builtin_created_next_steps_p3", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Next steps.
/// </summary>
public static string builtin_created_next_steps_title {
get {
return ResourceManager.GetString("builtin_created_next_steps_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Creating new extension....
/// </summary>
public static string builtin_creating_extension_message {
get {
return ResourceManager.GetString("builtin_creating_extension_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Built-in commands.
/// </summary>
public static string builtin_display_name {
get {
return ResourceManager.GetString("builtin_display_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View log.
/// </summary>
public static string builtin_log_name {
get {
return ResourceManager.GetString("builtin_log_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Log.
/// </summary>
public static string builtin_log_page_name {
get {
return ResourceManager.GetString("builtin_log_page_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View log messages.
/// </summary>
public static string builtin_log_subtitle {
get {
return ResourceManager.GetString("builtin_log_subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View log.
/// </summary>
public static string builtin_log_title {
get {
return ResourceManager.GetString("builtin_log_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Creates a project for a new Command Palette extension.
/// </summary>
public static string builtin_new_extension_subtitle {
get {
return ResourceManager.GetString("builtin_new_extension_subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open Settings.
/// </summary>
public static string builtin_open_settings_name {
get {
return ResourceManager.GetString("builtin_open_settings_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette settings.
/// </summary>
public static string builtin_open_settings_subtitle {
get {
return ResourceManager.GetString("builtin_open_settings_subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Exit Command Palette.
/// </summary>
public static string builtin_quit_subtitle {
get {
return ResourceManager.GetString("builtin_quit_subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reload Command Palette extensions.
/// </summary>
public static string builtin_reload_display_title {
get {
return ResourceManager.GetString("builtin_reload_display_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reload.
/// </summary>
public static string builtin_reload_name {
get {
return ResourceManager.GetString("builtin_reload_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reload Command Palette extensions.
/// </summary>
public static string builtin_reload_subtitle {
get {
return ResourceManager.GetString("builtin_reload_subtitle", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,239 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="builtin_open_settings_subtitle" xml:space="preserve">
<value>Open Command Palette settings</value>
</data>
<data name="builtin_new_extension_subtitle" xml:space="preserve">
<value>Creates a project for a new Command Palette extension</value>
</data>
<data name="builtin_quit_subtitle" xml:space="preserve">
<value>Exit Command Palette</value>
</data>
<data name="builtin_display_name" xml:space="preserve">
<value>Built-in commands</value>
</data>
<data name="builtin_log_subtitle" xml:space="preserve">
<value>View log messages</value>
</data>
<data name="builtin_log_title" xml:space="preserve">
<value>View log</value>
</data>
<data name="builtin_reload_subtitle" xml:space="preserve">
<value>Reload Command Palette extensions</value>
</data>
<data name="builtin_reload_name" xml:space="preserve">
<value>Reload</value>
</data>
<data name="builtin_log_name" xml:space="preserve">
<value>View log</value>
</data>
<data name="builtin_log_page_name" xml:space="preserve">
<value>Log</value>
</data>
<data name="builtin_creating_extension_message" xml:space="preserve">
<value>Creating new extension...</value>
</data>
<data name="builtin_create_extension_name" xml:space="preserve">
<value>Open</value>
</data>
<data name="builtin_create_extension_title" xml:space="preserve">
<value>Create a new extension</value>
</data>
<data name="builtin_open_settings_name" xml:space="preserve">
<value>Open Settings</value>
</data>
<data name="builtin_create_extension_success" xml:space="preserve">
<value>Successfully created your new extension!</value>
</data>
<data name="builtin_created_in_text" xml:space="preserve">
<value>Your new extension '${displayName}' was created in:</value>
<comment>{Locked="'${displayName}'"}</comment>
</data>
<data name="builtin_created_next_steps_title" xml:space="preserve">
<value>Next steps</value>
</data>
<data name="builtin_created_next_steps" xml:space="preserve">
<value>Now that your extension project has been created, open the solution up in Visual Studio to start writing your extension code.</value>
</data>
<data name="builtin_created_next_steps_p2" xml:space="preserve">
<value>Navigate to `${name}Page.cs` to start adding items to the list, or to `${name}CommandsProvider.cs` to add new commands.</value>
<comment>{Locked="`${name}Page.cs`", "`${name}CommandsProvider.cs`"}</comment>
</data>
<data name="builtin_created_next_steps_p3" xml:space="preserve">
<value>Once you're ready to test deploy the package locally with Visual Studio, then run the \"Reload\" command in the Command Palette to load your new extension.</value>
</data>
<data name="builtin_create_extension_page_title" xml:space="preserve">
<value>Create your new extension</value>
</data>
<data name="builtin_create_extension_page_text" xml:space="preserve">
<value>Use this page to create a new extension project.</value>
</data>
<data name="builtin_create_extension_name_header" xml:space="preserve">
<value>Extension name</value>
</data>
<data name="builtin_create_extension_name_description" xml:space="preserve">
<value>This is the name of your new extension project. It should be a valid C# class name. Best practice is to also include the word 'Extension' in the name.</value>
</data>
<data name="builtin_create_extension_name_label" xml:space="preserve">
<value>Extension name</value>
</data>
<data name="builtin_create_extension_name_required" xml:space="preserve">
<value>Extension name is required, without spaces</value>
</data>
<data name="builtin_create_extension_display_name_header" xml:space="preserve">
<value>Display name</value>
</data>
<data name="builtin_create_extension_display_name_description" xml:space="preserve">
<value>The name of your extension as users will see it.</value>
</data>
<data name="builtin_create_extension_display_name_label" xml:space="preserve">
<value>Display name</value>
</data>
<data name="builtin_create_extension_display_name_required" xml:space="preserve">
<value>Display name is required</value>
</data>
<data name="builtin_create_extension_directory_header" xml:space="preserve">
<value>Output path</value>
</data>
<data name="builtin_create_extension_directory_description" xml:space="preserve">
<value>Where should the new extension be created? This path will be created if it doesn't exist</value>
</data>
<data name="builtin_create_extension_directory_label" xml:space="preserve">
<value>Output path</value>
</data>
<data name="builtin_create_extension_directory_required" xml:space="preserve">
<value>Output path is required</value>
</data>
<data name="builtin_create_extension_open_solution" xml:space="preserve">
<value>Open Solution</value>
</data>
<data name="builtin_create_extension_open_directory" xml:space="preserve">
<value>Open directory</value>
</data>
<data name="builtin_create_extension_create_another" xml:space="preserve">
<value>Create another</value>
</data>
<data name="builtin_create_extension_submit" xml:space="preserve">
<value>Create extension</value>
</data>
<data name="builtin_reload_display_title" xml:space="preserve">
<value>Reload Command Palette extensions</value>
</data>
</root>

View File

@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
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}";
[JsonIgnore]
public bool IsBuiltin => string.IsNullOrEmpty(PackageFamilyName);
public ProviderSettings(CommandProviderWrapper wrapper)
{
Connect(wrapper);
}
[JsonConstructor]
public ProviderSettings(bool isEnabled)
{
IsEnabled = isEnabled;
}
public void Connect(CommandProviderWrapper wrapper)
{
PackageFamilyName = wrapper.Extension?.PackageFamilyName ?? string.Empty;
Id = wrapper.DisplayName;
ProviderDisplayName = wrapper.DisplayName;
if (ProviderId == "/")
{
throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!");
}
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.Common.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ProviderSettingsViewModel(
CommandProviderWrapper _provider,
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";
[MemberNotNullWhen(true, nameof(Extension))]
public bool IsFromExtension => _provider.Extension != null;
public IExtensionWrapper? Extension => _provider.Extension;
public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty;
public IconInfoViewModel Icon => _provider.Icon;
public bool IsEnabled
{
get => _providerSettings.IsEnabled;
set => _providerSettings.IsEnabled = value;
}
public bool HasSettings => _provider.Settings != null && _provider.Settings.SettingsPage != null;
public ContentPageViewModel? SettingsPage => HasSettings ? _provider?.Settings?.SettingsPage : null;
[field: AllowNull]
public List<TopLevelViewModel> TopLevelCommands
{
get
{
if (field == null)
{
field = BuildTopLevelViewModels();
}
return field;
}
}
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 results;
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class RecentCommandsManager : ObservableObject
{
[JsonInclude]
private List<HistoryItem> History { get; set; } = [];
public RecentCommandsManager()
{
}
public int GetCommandHistoryWeight(string commandId)
{
var entry = History
.Index()
.Where(item => item.Item.CommandId == commandId)
.FirstOrDefault();
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
// match after one use.
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
if (entry.Item != null)
{
var index = entry.Index;
// First, add some weight based on how early in the list this appears
var bucket = index switch
{
var i when index <= 2 => 35,
var i when index <= 10 => 25,
var i when index <= 15 => 15,
var i when index <= 35 => 10,
_ => 5,
};
// Then, add weight for how often this is used, but cap the weight from usage.
var uses = Math.Min(entry.Item.Uses * 5, 35);
return bucket + uses;
}
return 0;
}
public void AddHistoryItem(string commandId)
{
var entry = History
.Where(item => item.CommandId == commandId)
.FirstOrDefault();
if (entry == null)
{
var newitem = new HistoryItem() { CommandId = commandId, Uses = 1 };
History.Insert(0, newitem);
}
else
{
History.Remove(entry);
entry.Uses++;
History.Insert(0, entry);
}
if (History.Count > 50)
{
History.RemoveRange(50, History.Count - 50);
}
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
public static class Helper
{
private static readonly global::PowerToys.Interop.LayoutMapManaged LayoutMap = new();
public static string GetKeyName(uint key)
{
return LayoutMap.GetKeyName(key);
}
public static uint GetKeyValue(string key)
{
return LayoutMap.GetKeyValue(key);
}
public static readonly uint VirtualKeyWindows = global::PowerToys.Interop.Constants.VK_WIN_BOTH;
}

View File

@@ -0,0 +1,230 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
public record HotkeySettings// : ICmdLineRepresentable
{
private const int VKTAB = 0x09;
public HotkeySettings()
{
Win = false;
Ctrl = false;
Alt = false;
Shift = false;
Code = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="HotkeySettings"/> class.
/// </summary>
/// <param name="win">Should Windows key be used</param>
/// <param name="ctrl">Should Ctrl key be used</param>
/// <param name="alt">Should Alt key be used</param>
/// <param name="shift">Should Shift key be used</param>
/// <param name="code">Go to https://learn.microsoft.com/windows/win32/inputdev/virtual-key-codes to see list of v-keys</param>
public HotkeySettings(bool win, bool ctrl, bool alt, bool shift, int code)
{
Win = win;
Ctrl = ctrl;
Alt = alt;
Shift = shift;
Code = code;
}
[JsonPropertyName("win")]
public bool Win { get; set; }
[JsonPropertyName("ctrl")]
public bool Ctrl { get; set; }
[JsonPropertyName("alt")]
public bool Alt { get; set; }
[JsonPropertyName("shift")]
public bool Shift { get; set; }
[JsonPropertyName("code")]
public int Code { get; set; }
// This is currently needed for FancyZones, we need to unify these two objects
// see src\common\settings_objects.h
[JsonPropertyName("key")]
public string Key { get; set; } = string.Empty;
public override string ToString()
{
var output = new StringBuilder();
if (Win)
{
output.Append("Win + ");
}
if (Ctrl)
{
output.Append("Ctrl + ");
}
if (Alt)
{
output.Append("Alt + ");
}
if (Shift)
{
output.Append("Shift + ");
}
if (Code > 0)
{
var localKey = Helper.GetKeyName((uint)Code);
output.Append(localKey);
}
else if (output.Length >= 2)
{
output.Remove(output.Length - 2, 2);
}
return output.ToString();
}
public List<object> GetKeysList()
{
var shortcutList = new List<object>();
if (Win)
{
shortcutList.Add(92); // The Windows key or button.
}
if (Ctrl)
{
shortcutList.Add("Ctrl");
}
if (Alt)
{
shortcutList.Add("Alt");
}
if (Shift)
{
shortcutList.Add("Shift");
// shortcutList.Add(16); // The Shift key or button.
}
if (Code > 0)
{
switch (Code)
{
// https://learn.microsoft.com/uwp/api/windows.system.virtualkey?view=winrt-20348
case 38: // The Up Arrow key or button.
case 40: // The Down Arrow key or button.
case 37: // The Left Arrow key or button.
case 39: // The Right Arrow key or button.
// case 8: // The Back key or button.
// case 13: // The Enter key or button.
shortcutList.Add(Code);
break;
default:
var localKey = Helper.GetKeyName((uint)Code);
shortcutList.Add(localKey);
break;
}
}
return shortcutList;
}
public bool IsValid()
{
return IsAccessibleShortcut() ? false : (Alt || Ctrl || Win || Shift) && Code != 0;
}
public bool IsEmpty()
{
return !Alt && !Ctrl && !Win && !Shift && Code == 0;
}
public bool IsAccessibleShortcut()
{
// Shift+Tab and Tab are accessible shortcuts
return (!Alt && !Ctrl && !Win && Shift && Code == VKTAB)
|| (!Alt && !Ctrl && !Win && !Shift && Code == VKTAB);
}
public static bool TryParseFromCmd(string cmd, out object? result)
{
bool win = false, ctrl = false, alt = false, shift = false;
var code = 0;
var parts = cmd.Split('+');
foreach (var part in parts)
{
switch (part.Trim().ToLower(CultureInfo.InvariantCulture))
{
case "win":
win = true;
break;
case "ctrl":
ctrl = true;
break;
case "alt":
alt = true;
break;
case "shift":
shift = true;
break;
default:
if (!TryParseKeyCode(part, out code))
{
result = null;
return false;
}
break;
}
}
result = new HotkeySettings(win, ctrl, alt, shift, code);
return true;
}
private static bool TryParseKeyCode(string key, out int keyCode)
{
// ASCII symbol
if (key.Length == 1 && char.IsLetterOrDigit(key[0]))
{
keyCode = char.ToUpper(key[0], CultureInfo.InvariantCulture);
return true;
}
// VK code
else if (key.Length == 4 && key.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
return int.TryParse(key.AsSpan(2), NumberStyles.HexNumber, null, out keyCode);
}
// Alias
else
{
keyCode = (int)Helper.GetKeyValue(key);
return keyCode != 0;
}
}
public bool TryToCmdRepresentable(out string result)
{
result = ToString();
result = result.Replace(" ", null);
return true;
}
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class SettingsModel : ObservableObject
{
[JsonIgnore]
public static readonly string FilePath;
public event TypedEventHandler<SettingsModel, object?>? SettingsChanged;
///////////////////////////////////////////////////////////////////////////
// SETTINGS HERE
public HotkeySettings? Hotkey { get; set; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space
public bool ShowAppDetails { get; set; }
public bool HotkeyGoesHome { get; set; }
public bool BackspaceGoesBack { get; set; }
public bool SingleClickActivates { get; set; }
public bool HighlightSearchOnActivate { get; set; } = true;
public Dictionary<string, ProviderSettings> ProviderSettings { get; set; } = [];
public Dictionary<string, CommandAlias> Aliases { get; set; } = [];
public List<TopLevelHotkey> CommandHotkeys { get; set; } = [];
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
static SettingsModel()
{
FilePath = SettingsJsonPath();
}
public static SettingsModel LoadSettings()
{
if (string.IsNullOrEmpty(FilePath))
{
throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadSettings)}");
}
if (!File.Exists(FilePath))
{
Debug.WriteLine("The provided settings file does not exist");
return new();
}
try
{
// Read the JSON content from the file
var jsonContent = File.ReadAllText(FilePath);
var loaded = JsonSerializer.Deserialize<SettingsModel>(jsonContent, _deserializerOptions);
Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse");
return loaded ?? new();
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return new();
}
public static void SaveSettings(SettingsModel model)
{
if (string.IsNullOrEmpty(FilePath))
{
throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveSettings)}");
}
try
{
// Serialize the main dictionary to JSON and save it to the file
var settingsJson = JsonSerializer.Serialize(model, _serializerOptions);
// Is it valid JSON?
if (JsonNode.Parse(settingsJson) is JsonObject newSettings)
{
// Now, read the existing content from the file
var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}";
// Is it valid JSON?
if (JsonNode.Parse(oldContent) is JsonObject savedSettings)
{
foreach (var item in newSettings)
{
savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null;
}
var serialized = savedSettings.ToJsonString(_serializerOptions);
File.WriteAllText(FilePath, serialized);
// TODO: Instead of just raising the event here, we should
// have a file change watcher on the settings file, and
// reload the settings then
model.SettingsChanged?.Invoke(model, null);
}
else
{
Debug.WriteLine("Failed to parse settings file as JsonObject.");
}
}
else
{
Debug.WriteLine("Failed to parse settings file as JsonObject.");
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}
internal static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the settings is just next to the exe
return Path.Combine(directory, "settings.json");
}
private static readonly JsonSerializerOptions _serializerOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
private static readonly JsonSerializerOptions _deserializerOptions = new()
{
PropertyNameCaseInsensitive = true,
IncludeFields = true,
Converters = { new JsonStringEnumConverter() },
AllowTrailingCommas = true,
};
}
public enum MonitorBehavior
{
ToMouse = 0,
ToPrimary = 1,
ToFocusedWindow = 2,
InPlace = 3,
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class SettingsViewModel
{
private readonly SettingsModel _settings;
private readonly IServiceProvider _serviceProvider;
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
set
{
_settings.Hotkey = value;
Save();
}
}
public bool ShowAppDetails
{
get => _settings.ShowAppDetails;
set
{
_settings.ShowAppDetails = value;
Save();
}
}
public bool HotkeyGoesHome
{
get => _settings.HotkeyGoesHome;
set
{
_settings.HotkeyGoesHome = value;
Save();
}
}
public bool BackspaceGoesBack
{
get => _settings.BackspaceGoesBack;
set
{
_settings.BackspaceGoesBack = value;
Save();
}
}
public bool SingleClickActivates
{
get => _settings.SingleClickActivates;
set
{
_settings.SingleClickActivates = value;
Save();
}
}
public bool HighlightSearchOnActivate
{
get => _settings.HighlightSearchOnActivate;
set
{
_settings.HighlightSearchOnActivate = value;
Save();
}
}
public int MonitorPositionIndex
{
get => (int)_settings.SummonOn;
set
{
_settings.SummonOn = (MonitorBehavior)value;
Save();
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = [];
public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler)
{
_settings = settings;
_serviceProvider = serviceProvider;
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;
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 settingsModel = new ProviderSettingsViewModel(item, providerSettings, _serviceProvider);
CommandProviders.Add(settingsModel);
}
}
private IEnumerable<CommandProviderWrapper> GetCommandProviders()
{
var manager = _serviceProvider.GetService<TopLevelCommandManager>()!;
var allProviders = manager.CommandProviders;
return allProviders;
}
private void Save() => SettingsModel.SaveSettings(_settings);
}

View File

@@ -0,0 +1,177 @@
// 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.Common;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Windows.Win32;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskScheduler _scheduler) : ObservableObject
{
[ObservableProperty]
public partial bool IsLoaded { get; set; } = false;
[ObservableProperty]
public partial DetailsViewModel? Details { get; set; }
[ObservableProperty]
public partial bool IsDetailsVisible { get; set; }
[ObservableProperty]
public partial PageViewModel CurrentPage { get; set; } = new LoadingPageViewModel(null, _scheduler);
private MainListPage? _mainListPage;
private IExtensionWrapper? _activeExtension;
[RelayCommand]
public async Task<bool> LoadAsync()
{
var tlcManager = _serviceProvider.GetService<TopLevelCommandManager>();
await tlcManager!.LoadBuiltinsAsync();
IsLoaded = true;
// Built-ins have loaded. We can display our page at this point.
_mainListPage = new MainListPage(_serviceProvider);
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_mainListPage)));
_ = Task.Run(async () =>
{
// After loading built-ins, and starting navigation, kick off a thread to load extensions.
tlcManager.LoadExtensionsCommand.Execute(null);
await tlcManager.LoadExtensionsCommand.ExecutionTask!;
if (tlcManager.LoadExtensionsCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
// TODO: Handle failure case
}
});
return true;
}
public void LoadPageViewModel(PageViewModel viewModel)
{
// Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems.
// IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar
// This triggers that load generally with the InitializeCommand asynchronously when we navigate to a page.
// We could re-track the page loading status, if we need it more granularly below again, but between the initialized and error message, we can infer some status.
// We could also maybe move this thread offloading we do for loading into PageViewModel and better communicate between the two... a few different options.
////LoadedState = ViewModelLoadedState.Loading;
if (!viewModel.IsInitialized
&& viewModel.InitializeCommand != null)
{
_ = Task.Run(async () =>
{
// You know, this creates the situation where we wait for
// both loading page properties, AND the items, before we
// display anything.
//
// We almost need to do an async await on initialize, then
// just a fire-and-forget on FetchItems.
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
// Definitely some more clean-up to do, but at least its centralized to one spot now.
viewModel.InitializeCommand.Execute(null);
await viewModel.InitializeCommand.ExecutionTask!;
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
// TODO: Handle failure case
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
{
Logger.LogError(ex.ToString());
}
// TODO GH #239 switch back when using the new MD text block
// _ = _queue.EnqueueAsync(() =>
/*_queue.TryEnqueue(new(() =>
{
LoadedState = ViewModelLoadedState.Error;
}));*/
}
else
{
// TODO GH #239 switch back when using the new MD text block
// _ = _queue.EnqueueAsync(() =>
_ = Task.Factory.StartNew(
() =>
{
var result = (bool)viewModel.InitializeCommand.ExecutionTask.GetResultOrDefault()!;
CurrentPage = viewModel; // result ? viewModel : null;
////LoadedState = result ? ViewModelLoadedState.Loaded : ViewModelLoadedState.Error;
},
CancellationToken.None,
TaskCreationOptions.None,
_scheduler);
}
});
}
else
{
CurrentPage = viewModel;
////LoadedState = ViewModelLoadedState.Loaded;
}
}
public void PerformTopLevelCommand(PerformCommandMessage message)
{
if (_mainListPage == null)
{
return;
}
if (message.Context is IListItem listItem)
{
_mainListPage.UpdateHistory(listItem);
}
}
public void SetActiveExtension(IExtensionWrapper? extension)
{
if (extension != _activeExtension)
{
// There's not really a CoDisallowSetForegroundWindow, so we don't
// need to handle that
_activeExtension = extension;
var extensionComObject = _activeExtension?.GetExtensionObject();
if (extensionComObject != null)
{
try
{
unsafe
{
var hr = PInvoke.CoAllowSetForegroundWindow(extensionComObject);
if (hr != 0)
{
Logger.LogWarning($"Error giving foreground rights: 0x{hr.Value:X8}");
}
}
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
}
}
}
public void GoHome()
{
SetActiveExtension(null);
}
}

View File

@@ -0,0 +1,99 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class StatusMessageViewModel : ExtensionObjectViewModel
{
public ExtensionObject<IStatusMessage> Model { get; }
public string Message { get; private set; } = string.Empty;
public MessageState State { get; private set; } = MessageState.Info;
public string ExtensionPfn { get; set; } = string.Empty;
public ProgressViewModel? Progress { get; private set; }
public bool HasProgress => Progress != null;
// public bool IsIndeterminate => Progress != null && Progress.IsIndeterminate;
// public double ProgressValue => (Progress?.ProgressPercent ?? 0) / 100.0;
public StatusMessageViewModel(IStatusMessage message, WeakReference<IPageContext> context)
: base(context)
{
Model = new(message);
}
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
Message = model.Message;
State = model.State;
var modelProgress = model.Progress;
if (modelProgress != null)
{
Progress = new(modelProgress, this.PageContext);
Progress.InitializeProperties();
UpdateProperty(nameof(HasProgress));
}
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this.Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Message):
this.Message = model.Message;
break;
case nameof(State):
this.State = model.State;
break;
case nameof(Progress):
var modelProgress = model.Progress;
if (modelProgress != null)
{
Progress = new(modelProgress, this.PageContext);
Progress.InitializeProperties();
}
else
{
Progress = null;
}
UpdateProperty(nameof(HasProgress));
break;
}
UpdateProperty(propertyName);
}
}

View File

@@ -0,0 +1,49 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TagViewModel(ITag _tag, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
{
private readonly ExtensionObject<ITag> _tagModel = new(_tag);
public string ToolTip => string.IsNullOrEmpty(ModelToolTip) ? Text : ModelToolTip;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string Text { get; private set; } = string.Empty;
public string ModelToolTip { get; private set; } = string.Empty;
public OptionalColor Foreground { get; private set; }
public OptionalColor Background { get; private set; }
public IconInfoViewModel Icon { get; private set; } = new(null);
public override void InitializeProperties()
{
var model = _tagModel.Unsafe;
if (model == null)
{
return;
}
Text = model.Text;
Foreground = model.Foreground;
Background = model.Background;
ModelToolTip = model.ToolTip;
Icon = new(model.Icon);
Icon.InitializeProperties();
UpdateProperty(nameof(Text));
UpdateProperty(nameof(Foreground));
UpdateProperty(nameof(Background));
UpdateProperty(nameof(ToolTip));
UpdateProperty(nameof(Icon));
}
}

View File

@@ -0,0 +1,15 @@
// 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.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ToastViewModel : ObservableObject
{
[ObservableProperty]
public partial string ToastMessage { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,224 @@
// 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

@@ -0,0 +1,313 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>
{
private readonly IServiceProvider _serviceProvider;
private readonly TaskScheduler _taskScheduler;
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
public TopLevelCommandManager(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
}
public ObservableCollection<TopLevelCommandItemWrapper> TopLevelCommands { get; set; } = [];
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
public IEnumerable<CommandProviderWrapper> CommandProviders => _builtInCommands.Concat(_extensionCommandProviders);
public async Task<bool> LoadBuiltinsAsync()
{
_builtInCommands.Clear();
// Load built-In commands first. These are all in-proc, and
// owned by our ServiceProvider.
var builtInCommands = _serviceProvider.GetServices<ICommandProvider>();
foreach (var provider in builtInCommands)
{
CommandProviderWrapper wrapper = new(provider, _taskScheduler);
_builtInCommands.Add(wrapper);
await LoadTopLevelCommandsFromProvider(wrapper);
}
return true;
}
// May be called from a background thread
private async Task LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{
await commandProvider.LoadTopLevelCommands();
var makeAndAdd = (ICommandItem? i, bool fallback) =>
{
TopLevelCommandItemWrapper wrapper = new(
new(i), fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, _serviceProvider);
lock (TopLevelCommands)
{
TopLevelCommands.Add(wrapper);
}
};
await Task.Factory.StartNew(
() =>
{
foreach (var i in commandProvider.TopLevelItems)
{
makeAndAdd(i, false);
}
foreach (var i in commandProvider.FallbackItems)
{
makeAndAdd(i, true);
}
},
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler);
commandProvider.CommandsChanged += CommandProvider_CommandsChanged;
}
// By all accounts, we're already on a background thread (the COM call
// to handle the event shouldn't be on the main thread.). But just to
// be sure we don't block the caller, hop off this thread
private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, IItemsChangedEventArgs args) =>
_ = Task.Run(async () => await UpdateCommandsForProvider(sender, args));
/// <summary>
/// Called when a command provider raises its ItemsChanged event. We'll
/// remove the old commands from the top-level list and try to put the new
/// ones in the same place in the list.
/// </summary>
/// <param name="sender">The provider who's commands changed</param>
/// <param name="args">the ItemsChangedEvent the provider raised</param>
/// <returns>an awaitable task</returns>
private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args)
{
// 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 = [];
var startIndex = -1;
var firstCommand = sender.TopLevelItems[0];
var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length;
// Tricky: all Commands from a single provider get added to the
// top-level list all together, in a row. So if we find just the first
// one, we can slice it out and insert the new ones there.
for (var i = 0; i < clone.Count; i++)
{
var wrapper = clone[i];
try
{
var thisCommand = wrapper.Model.Unsafe;
if (thisCommand != null)
{
var isTheSame = thisCommand == firstCommand;
if (isTheSame)
{
startIndex = i;
break;
}
}
}
catch
{
}
}
// Fetch the new items
await sender.LoadTopLevelCommands();
foreach (var i in sender.TopLevelItems)
{
newItems.Add(new(new(i), false, sender.ExtensionHost, sender.ProviderId, _serviceProvider));
}
foreach (var i in sender.FallbackItems)
{
newItems.Add(new(new(i), true, sender.ExtensionHost, sender.ProviderId, _serviceProvider));
}
// Slice out the old commands
if (startIndex != -1)
{
clone.RemoveRange(startIndex, commandsToRemove);
}
else
{
// ... or, just stick them at the end (this is unexpected)
startIndex = clone.Count;
}
// add the new commands into the list at the place we found the old ones
clone.InsertRange(startIndex, newItems);
// now update the actual observable list with the new contents
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
}
public async Task ReloadAllCommandsAsync()
{
IsLoading = true;
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
await extensionService.SignalStopExtensionsAsync();
lock (TopLevelCommands)
{
TopLevelCommands.Clear();
}
await LoadBuiltinsAsync();
_ = Task.Run(LoadExtensionsAsync);
}
// Load commands from our extensions. Called on a background thread.
// Currently, this
// * queries the package catalog,
// * starts all the extensions,
// * then fetches the top-level commands from them.
// TODO In the future, we'll probably abstract some of this away, to have
// separate extension tracking vs stub loading.
[RelayCommand]
public async Task<bool> LoadExtensionsAsync()
{
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
extensionService.OnExtensionAdded -= ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved -= ExtensionService_OnExtensionRemoved;
var extensions = await extensionService.GetInstalledExtensionsAsync();
_extensionCommandProviders.Clear();
if (extensions != null)
{
await StartExtensionsAndGetCommands(extensions);
}
extensionService.OnExtensionAdded += ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved += ExtensionService_OnExtensionRemoved;
IsLoading = false;
return true;
}
private void ExtensionService_OnExtensionAdded(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
{
// When we get an extension install event, hop off to a BG thread
_ = Task.Run(async () =>
{
// for each newly installed extension, start it and get commands
// from it. One single package might have more than one
// IExtensionWrapper in it.
await StartExtensionsAndGetCommands(extensions);
});
}
private async Task StartExtensionsAndGetCommands(IEnumerable<IExtensionWrapper> extensions)
{
// TODO This most definitely needs a lock
foreach (var extension in extensions)
{
try
{
// start it ...
await extension.StartExtensionAsync();
// ... and fetch the command provider from it.
CommandProviderWrapper wrapper = new(extension, _taskScheduler);
_extensionCommandProviders.Add(wrapper);
await LoadTopLevelCommandsFromProvider(wrapper);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
}
}
private void ExtensionService_OnExtensionRemoved(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
{
// When we get an extension uninstall event, hop off to a BG thread
_ = Task.Run(
async () =>
{
// Then find all the top-level commands that belonged to that extension
List<TopLevelCommandItemWrapper> commandsToRemove = [];
lock (TopLevelCommands)
{
foreach (var extension in extensions)
{
foreach (var command in TopLevelCommands)
{
var host = command.ExtensionHost;
if (host?.Extension == extension)
{
commandsToRemove.Add(command);
}
}
}
}
// Then back on the UI thread (remember, TopLevelCommands is
// Observable, so you can't touch it on the BG thread)...
await Task.Factory.StartNew(
() =>
{
// ... remove all the deleted commands.
lock (TopLevelCommands)
{
if (commandsToRemove.Count != 0)
{
foreach (var deleted in commandsToRemove)
{
TopLevelCommands.Remove(deleted);
}
}
}
},
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler);
});
}
public TopLevelCommandItemWrapper? LookupCommand(string id)
{
lock (TopLevelCommands)
{
foreach (var command in TopLevelCommands)
{
if (command.Id == id)
{
return command;
}
}
}
return null;
}
public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false);
}

View File

@@ -0,0 +1,74 @@
// 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

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels;
public class TopLevelHotkey(HotkeySettings? hotkey, string commandId)
{
public string CommandId { get; set; } = commandId;
public HotkeySettings? Hotkey { get; set; } = hotkey;
}

View File

@@ -0,0 +1,96 @@
// 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.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelViewModel : ObservableObject
{
private readonly SettingsModel _settings;
private readonly IServiceProvider _serviceProvider;
// 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;
public IconInfoViewModel Icon { get; private set; }
public string Title => _item.Title;
public string Subtitle => _item.Subtitle;
public HotkeySettings? Hotkey
{
get => _item.Hotkey;
set
{
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(_item.Id, value);
_item.Hotkey = value;
Save();
}
}
private string _aliasText;
public string AliasText
{
get => _aliasText;
set
{
if (SetProperty(ref _aliasText, value))
{
UpdateAlias();
}
}
}
private bool _isDirectAlias;
public bool IsDirectAlias
{
get => _isDirectAlias;
set
{
if (SetProperty(ref _isDirectAlias, value))
{
UpdateAlias();
}
}
}
public TopLevelViewModel(TopLevelCommandItemWrapper item, SettingsModel settings, IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_settings = settings;
_item = item;
Icon = new(item.Icon ?? item.Command?.Icon);
Icon.InitializeProperties();
var aliases = _serviceProvider.GetService<AliasManager>()!;
_isDirectAlias = _item.Alias?.IsDirect ?? false;
_aliasText = _item.Alias?.Alias ?? string.Empty;
}
private void Save() => SettingsModel.SaveSettings(_settings);
private void UpdateAlias()
{
if (string.IsNullOrWhiteSpace(_aliasText))
{
_item.UpdateAlias(null);
}
else
{
var newAlias = new CommandAlias(_aliasText, _item.Id, _isDirectAlias);
_item.UpdateAlias(newAlias);
}
Save();
}
}