mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-24 04:00:02 +01:00
so we can determine if we want to pin things or not. We need to know if the extension supports the version of the API with that method on it. It feels super weird to just have CommandItemViewModel do this though - I think the more correct solution would be to have a ContextMenuFactory that generates the context menu for a command item, and then have the UI layer pass that in. But also Jolley's refactor will probably deal away with all this so
613 lines
18 KiB
C#
613 lines
18 KiB
C#
// 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 ManagedCommon;
|
|
using Microsoft.CmdPal.Core.Common.Helpers;
|
|
using Microsoft.CmdPal.Core.ViewModels;
|
|
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Dock;
|
|
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
|
using Microsoft.CommandPalette.Extensions;
|
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Windows.Foundation;
|
|
using WyHash;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider
|
|
{
|
|
private readonly SettingsModel _settings;
|
|
private readonly ProviderSettings _providerSettings;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly CommandItemViewModel _commandItemViewModel;
|
|
private readonly DockViewModel? _dockViewModel;
|
|
|
|
private readonly string _commandProviderId;
|
|
|
|
// private readonly bool _providerSupportsPinning;
|
|
private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id;
|
|
|
|
private string _fallbackId = string.Empty;
|
|
|
|
private string _generatedId = string.Empty;
|
|
|
|
private HotkeySettings? _hotkey;
|
|
private IIconInfo? _initialIcon;
|
|
|
|
private CommandAlias? Alias { get; set; }
|
|
|
|
public bool IsFallback { get; private set; }
|
|
|
|
[ObservableProperty]
|
|
public partial ObservableCollection<Tag> Tags { get; set; } = [];
|
|
|
|
public string Id => string.IsNullOrWhiteSpace(IdFromModel) ? _generatedId : IdFromModel;
|
|
|
|
public CommandPaletteHost ExtensionHost { get; private set; }
|
|
|
|
public string ExtensionName => ExtensionHost.Extension?.ExtensionDisplayName ?? Properties.Resources.builtin_extension_name_fallback;
|
|
|
|
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
|
|
|
|
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
|
|
|
|
public string CommandProviderId => _commandProviderId;
|
|
|
|
public IconInfoViewModel IconViewModel => _commandItemViewModel.Icon;
|
|
|
|
// public bool ProviderSupportsPinning => _providerSupportsPinning;
|
|
|
|
////// ICommandItem
|
|
public string Title => _commandItemViewModel.Title;
|
|
|
|
public string Subtitle => _commandItemViewModel.Subtitle;
|
|
|
|
public IIconInfo Icon => (IIconInfo)IconViewModel;
|
|
|
|
public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon;
|
|
|
|
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
|
|
|
|
IContextItem?[] ICommandItem.MoreCommands => BuildContextMenu();
|
|
|
|
////// IListItem
|
|
ITag[] IListItem.Tags => Tags.ToArray();
|
|
|
|
IDetails? IListItem.Details => null;
|
|
|
|
string IListItem.Section => string.Empty;
|
|
|
|
string IListItem.TextToSuggest => string.Empty;
|
|
|
|
////// INotifyPropChanged
|
|
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
|
|
|
// Fallback items
|
|
public string DisplayTitle { get; private set; } = string.Empty;
|
|
|
|
public HotkeySettings? Hotkey
|
|
{
|
|
get => _hotkey;
|
|
set
|
|
{
|
|
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(Id, value);
|
|
UpdateHotkey();
|
|
UpdateTags();
|
|
Save();
|
|
}
|
|
}
|
|
|
|
public bool HasAlias => !string.IsNullOrEmpty(AliasText);
|
|
|
|
public string AliasText
|
|
{
|
|
get => Alias?.Alias ?? string.Empty;
|
|
set
|
|
{
|
|
var previousAlias = Alias?.Alias ?? string.Empty;
|
|
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
Alias = null;
|
|
}
|
|
else
|
|
{
|
|
if (Alias is CommandAlias a)
|
|
{
|
|
a.Alias = value;
|
|
}
|
|
else
|
|
{
|
|
Alias = new CommandAlias(value, Id);
|
|
}
|
|
}
|
|
|
|
// Only call HandleChangeAlias if there was an actual change.
|
|
if (previousAlias != Alias?.Alias)
|
|
{
|
|
HandleChangeAlias();
|
|
OnPropertyChanged(nameof(AliasText));
|
|
OnPropertyChanged(nameof(IsDirectAlias));
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool IsDirectAlias
|
|
{
|
|
get => Alias?.IsDirect ?? false;
|
|
set
|
|
{
|
|
if (Alias is CommandAlias a)
|
|
{
|
|
a.IsDirect = value;
|
|
}
|
|
|
|
HandleChangeAlias();
|
|
OnPropertyChanged(nameof(IsDirectAlias));
|
|
}
|
|
}
|
|
|
|
public bool IsEnabled
|
|
{
|
|
get
|
|
{
|
|
if (IsFallback)
|
|
{
|
|
if (_providerSettings.FallbackCommands.TryGetValue(_fallbackId, out var fallbackSettings))
|
|
{
|
|
return fallbackSettings.IsEnabled;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
return _providerSettings.IsEnabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dock properties
|
|
public bool IsDockBand { get; private set; }
|
|
|
|
public DockBandSettings? DockBandSettings
|
|
{
|
|
get
|
|
{
|
|
if (!IsDockBand)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var bandSettings = _settings.DockSettings.StartBands
|
|
.Concat(_settings.DockSettings.EndBands)
|
|
.FirstOrDefault(band => band.Id == this.Id);
|
|
if (bandSettings is null)
|
|
{
|
|
return new DockBandSettings()
|
|
{
|
|
Id = this.Id,
|
|
ShowLabels = true,
|
|
};
|
|
}
|
|
|
|
return bandSettings;
|
|
}
|
|
}
|
|
|
|
public TopLevelViewModel(
|
|
CommandItemViewModel item,
|
|
TopLevelType topLevelType,
|
|
CommandPaletteHost extensionHost,
|
|
string commandProviderId,
|
|
SettingsModel settings,
|
|
ProviderSettings providerSettings,
|
|
IServiceProvider serviceProvider,
|
|
ICommandItem? commandItem)/*,
|
|
bool providerSupportsPinning*/
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
_settings = settings;
|
|
_providerSettings = providerSettings;
|
|
_commandProviderId = commandProviderId;
|
|
|
|
// _providerSupportsPinning = providerSupportsPinning;
|
|
_commandItemViewModel = item;
|
|
|
|
IsFallback = topLevelType == TopLevelType.Fallback;
|
|
IsDockBand = topLevelType == TopLevelType.DockBand;
|
|
ExtensionHost = extensionHost;
|
|
if (IsFallback && commandItem is FallbackCommandItem fallback)
|
|
{
|
|
_fallbackId = fallback.Id;
|
|
}
|
|
|
|
item.PropertyChanged += Item_PropertyChanged;
|
|
|
|
_dockViewModel = serviceProvider.GetService<DockViewModel>();
|
|
}
|
|
|
|
internal void InitializeProperties()
|
|
{
|
|
ItemViewModel.SlowInitializeProperties();
|
|
GenerateId();
|
|
|
|
if (IsFallback)
|
|
{
|
|
var model = _commandItemViewModel.Model.Unsafe;
|
|
|
|
// RPC to check type
|
|
if (model is IFallbackCommandItem fallback)
|
|
{
|
|
DisplayTitle = fallback.DisplayTitle;
|
|
}
|
|
|
|
UpdateInitialIcon(false);
|
|
}
|
|
}
|
|
|
|
private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (!string.IsNullOrEmpty(e.PropertyName))
|
|
{
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName));
|
|
|
|
if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command))
|
|
{
|
|
GenerateId();
|
|
|
|
FetchAliasFromAliasManager();
|
|
UpdateHotkey();
|
|
UpdateTags();
|
|
UpdateInitialIcon();
|
|
}
|
|
else if (e.PropertyName == nameof(CommandItem.Icon))
|
|
{
|
|
UpdateInitialIcon();
|
|
}
|
|
else if (e.PropertyName == nameof(CommandItem.DataPackage))
|
|
{
|
|
DoOnUiThread(() =>
|
|
{
|
|
OnPropertyChanged(nameof(CommandItem.DataPackage));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateInitialIcon(bool raiseNotification = true)
|
|
{
|
|
if (_initialIcon != null || !_commandItemViewModel.Icon.IsSet)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_initialIcon = (IIconInfo?)_commandItemViewModel.Icon;
|
|
|
|
if (raiseNotification)
|
|
{
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(InitialIcon)));
|
|
});
|
|
}
|
|
}
|
|
|
|
private void Save() => SettingsModel.SaveSettings(_settings);
|
|
|
|
private void HandleChangeAlias()
|
|
{
|
|
SetAlias();
|
|
Save();
|
|
}
|
|
|
|
public void SetAlias()
|
|
{
|
|
var commandAlias = Alias is null
|
|
? null
|
|
: new CommandAlias(Alias.Alias, Alias.CommandId, Alias.IsDirect);
|
|
|
|
_serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, commandAlias);
|
|
UpdateTags();
|
|
}
|
|
|
|
private void FetchAliasFromAliasManager()
|
|
{
|
|
var am = _serviceProvider.GetService<AliasManager>();
|
|
if (am is not null)
|
|
{
|
|
var commandAlias = am.AliasFromId(Id);
|
|
if (commandAlias is not null)
|
|
{
|
|
// Decouple from the alias manager alias object
|
|
Alias = new CommandAlias(commandAlias.Alias, commandAlias.CommandId, commandAlias.IsDirect);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateHotkey()
|
|
{
|
|
var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault();
|
|
if (hotkey is not null)
|
|
{
|
|
_hotkey = hotkey.Hotkey;
|
|
}
|
|
}
|
|
|
|
private void UpdateTags()
|
|
{
|
|
List<Tag> tags = [];
|
|
|
|
if (Hotkey is not null)
|
|
{
|
|
tags.Add(new Tag() { Text = Hotkey.ToString() });
|
|
}
|
|
|
|
if (Alias is not null)
|
|
{
|
|
tags.Add(new Tag() { Text = Alias.SearchPrefix });
|
|
}
|
|
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
ListHelpers.InPlaceUpdateList(Tags, tags);
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Tags)));
|
|
});
|
|
}
|
|
|
|
private void GenerateId()
|
|
{
|
|
// Use WyHash64 to generate stable ID hashes.
|
|
// manually seeding with 0, so that the hash is stable across launches
|
|
var result = WyHash64.ComputeHash64(_commandProviderId + DisplayTitle + Title + Subtitle, seed: 0);
|
|
_generatedId = $"{_commandProviderId}{result}";
|
|
}
|
|
|
|
private void DoOnUiThread(Action action)
|
|
{
|
|
if (_commandItemViewModel.PageContext.TryGetTarget(out var pageContext))
|
|
{
|
|
Task.Factory.StartNew(
|
|
action,
|
|
CancellationToken.None,
|
|
TaskCreationOptions.None,
|
|
pageContext.Scheduler);
|
|
}
|
|
}
|
|
|
|
internal bool SafeUpdateFallbackTextSynchronous(string newQuery)
|
|
{
|
|
if (!IsFallback)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!IsEnabled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
return UnsafeUpdateFallbackSynchronous(newQuery);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex.ToString());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls UpdateQuery on our command, if we're a fallback item. This does
|
|
/// RPC work, so make sure you're calling it on a BG thread.
|
|
/// </summary>
|
|
/// <param name="newQuery">The new search text to pass to the extension</param>
|
|
/// <returns>true if our Title changed across this call</returns>
|
|
private bool UnsafeUpdateFallbackSynchronous(string newQuery)
|
|
{
|
|
var model = _commandItemViewModel.Model.Unsafe;
|
|
|
|
// RPC to check type
|
|
if (model is IFallbackCommandItem fallback)
|
|
{
|
|
var wasEmpty = string.IsNullOrEmpty(Title);
|
|
|
|
// RPC for method
|
|
fallback.FallbackHandler.UpdateQuery(newQuery);
|
|
var isEmpty = string.IsNullOrEmpty(Title);
|
|
return wasEmpty != isEmpty;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public PerformCommandMessage GetPerformCommandMessage()
|
|
{
|
|
return new PerformCommandMessage(this.CommandViewModel.Model, new Core.ViewModels.Models.ExtensionObject<IListItem>(this));
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}";
|
|
}
|
|
|
|
public IDictionary<string, object?> GetProperties()
|
|
{
|
|
return new Dictionary<string, object?>
|
|
{
|
|
[WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage,
|
|
};
|
|
}
|
|
|
|
private IContextItem?[] BuildContextMenu()
|
|
{
|
|
List<IContextItem?> contextItems = new();
|
|
|
|
foreach (var item in _commandItemViewModel.MoreCommands)
|
|
{
|
|
if (item is ISeparatorContextItem)
|
|
{
|
|
contextItems.Add(item as IContextItem);
|
|
}
|
|
else if (item is CommandContextItemViewModel commandItem)
|
|
{
|
|
contextItems.Add(commandItem.Model.Unsafe);
|
|
}
|
|
}
|
|
|
|
var dockEnabled = _settings.EnableDock;
|
|
if (dockEnabled && _dockViewModel is not null)
|
|
{
|
|
// Add a separator
|
|
contextItems.Add(new Separator());
|
|
|
|
var inStartBands = _settings.DockSettings.StartBands.Any(band => band.Id == this.Id);
|
|
var inEndBands = _settings.DockSettings.EndBands.Any(band => band.Id == this.Id);
|
|
var alreadyPinned = (inStartBands || inEndBands) &&
|
|
_settings.DockSettings.PinnedCommands.Contains(this.Id);
|
|
|
|
var pinCommand = new PinToDockCommand(
|
|
this,
|
|
!alreadyPinned,
|
|
_dockViewModel,
|
|
_settings,
|
|
_serviceProvider.GetService<TopLevelCommandManager>()!);
|
|
|
|
var contextItem = new CommandContextItem(pinCommand);
|
|
|
|
contextItems.Add(contextItem);
|
|
}
|
|
|
|
return contextItems.ToArray();
|
|
}
|
|
|
|
internal ICommandItem ToPinnedDockBandItem()
|
|
{
|
|
var item = new PinnedDockItem(item: this, id: Id);
|
|
|
|
return item;
|
|
}
|
|
|
|
internal TopLevelViewModel CloneAsBand()
|
|
{
|
|
return new TopLevelViewModel(
|
|
_commandItemViewModel,
|
|
TopLevelType.DockBand,
|
|
ExtensionHost,
|
|
_commandProviderId,
|
|
_settings,
|
|
_providerSettings,
|
|
_serviceProvider,
|
|
_commandItemViewModel.Model.Unsafe);
|
|
}
|
|
|
|
private sealed partial class PinToDockCommand : InvokableCommand
|
|
{
|
|
private readonly TopLevelViewModel _topLevelViewModel;
|
|
private readonly DockViewModel _dockViewModel;
|
|
private readonly SettingsModel _settings;
|
|
private readonly TopLevelCommandManager _topLevelCommandManager;
|
|
private readonly bool _pin;
|
|
|
|
public override IconInfo Icon => _pin ? Icons.PinIcon : Icons.UnpinIcon;
|
|
|
|
public override string Name => _pin ? Properties.Resources.dock_pin_command_name : Properties.Resources.dock_unpin_command_name;
|
|
|
|
public PinToDockCommand(
|
|
TopLevelViewModel topLevelViewModel,
|
|
bool pin,
|
|
DockViewModel dockViewModel,
|
|
SettingsModel settings,
|
|
TopLevelCommandManager topLevelCommandManager)
|
|
{
|
|
_topLevelViewModel = topLevelViewModel;
|
|
_dockViewModel = dockViewModel;
|
|
_settings = settings;
|
|
_topLevelCommandManager = topLevelCommandManager;
|
|
_pin = pin;
|
|
}
|
|
|
|
public override CommandResult Invoke()
|
|
{
|
|
Logger.LogDebug($"PinToDockCommand.Invoke({_pin}): {_topLevelViewModel.Id}");
|
|
if (_pin)
|
|
{
|
|
PinToDock();
|
|
}
|
|
else
|
|
{
|
|
UnpinFromDock();
|
|
}
|
|
|
|
// Notify that the MoreCommands have changed, so the context menu updates
|
|
_topLevelViewModel.PropChanged?.Invoke(
|
|
_topLevelViewModel,
|
|
new PropChangedEventArgs(nameof(ICommandItem.MoreCommands)));
|
|
return CommandResult.GoHome();
|
|
}
|
|
|
|
private void PinToDock()
|
|
{
|
|
// It's possible that the top-level command shares an ID with a
|
|
// band. In that case, we don't want to add it to PinnedCommands.
|
|
// PinnedCommands is just for top-level commands IDs that aren't
|
|
// otherwise bands.
|
|
//
|
|
// Check the top-level command ID against the bands first.
|
|
if (_topLevelCommandManager.DockBands.Any(band => band.Id == _topLevelViewModel.Id))
|
|
{
|
|
}
|
|
else
|
|
{
|
|
// In this case, the ID isn't another band, so add it to PinnedCommands.
|
|
if (!_settings.DockSettings.PinnedCommands.Contains(_topLevelViewModel.Id))
|
|
{
|
|
_settings.DockSettings.PinnedCommands.Add(_topLevelViewModel.Id);
|
|
}
|
|
}
|
|
|
|
// TODO! Deal with "the command ID is already pinned in
|
|
// PinnedCommands but not in one of StartBands/EndBands". I think
|
|
// we're already avoiding adding it to PinnedCommands above, but I
|
|
// think that PinDockBand below will create a duplicate VM for it.
|
|
_settings.DockSettings.StartBands.Add(new DockBandSettings()
|
|
{
|
|
Id = _topLevelViewModel.Id,
|
|
ShowLabels = true,
|
|
});
|
|
|
|
// Create a new band VM from our current TLVM. This will allow us to
|
|
// update the bands in the CommandProviderWrapper and the TLCM,
|
|
// without forcing a whole reload
|
|
var bandVm = _topLevelViewModel.CloneAsBand();
|
|
_topLevelCommandManager.PinDockBand(bandVm);
|
|
|
|
_topLevelViewModel.Save();
|
|
}
|
|
|
|
private void UnpinFromDock()
|
|
{
|
|
_settings.DockSettings.PinnedCommands.Remove(_topLevelViewModel.Id);
|
|
_settings.DockSettings.StartBands.RemoveAll(band => band.Id == _topLevelViewModel.Id);
|
|
_settings.DockSettings.EndBands.RemoveAll(band => band.Id == _topLevelViewModel.Id);
|
|
|
|
_topLevelViewModel.Save();
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum TopLevelType
|
|
{
|
|
Normal,
|
|
Fallback,
|
|
DockBand,
|
|
}
|