// 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.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext { public ExtensionObject Model => _commandItemModel; private readonly ExtensionObject _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 MoreCommands { get; private set; } = []; IEnumerable IContextMenuContext.MoreCommands => MoreCommands; private List ActualCommands => MoreCommands.OfType().ToList(); public bool HasMoreCommands => ActualCommands.Count > 0; public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; public CommandItemViewModel? PrimaryCommand => this; public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null; public bool ShouldBeVisible => !string.IsNullOrEmpty(Name); public List AllCommands { get { List l = _defaultCommandContextItem is 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 item, WeakReference errorContext) : base(errorContext) { _commandItemModel = item; Command = new(null, errorContext); } public void FastInitializeProperties() { if (IsFastInitialized) { return; } var model = _commandItemModel.Unsafe; if (model is 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 is null) { return; } Command.InitializeProperties(); var listIcon = model.Icon; if (listIcon is not 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)); // Load-bearing: if you don't raise a IsInitialized here, then // TopLevelViewModel will never know what the command's ID is, so it // will never be able to load Hotkeys & aliases UpdateProperty(nameof(IsInitialized)); Initialized |= InitializedState.Initialized; } public void SlowInitializeProperties() { if (IsSelectedInitialized) { return; } if (!IsInitialized) { InitializeProperties(); } var model = _commandItemModel.Unsafe; if (model is null) { return; } var more = model.MoreCommands; if (more is not null) { MoreCommands = more .Select(item => { if (item is ICommandContextItem contextItem) { return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; } else { return new SeparatorContextItemViewModel() as IContextItemViewModel; } }) .ToList(); } // Here, we're already theoretically in the async context, so we can // use Initialize straight up MoreCommands .OfType() .ToList() .ForEach(contextItem => { contextItem.SlowInitializeProperties(); }); if (!string.IsNullOrEmpty(model.Command?.Name)) { _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 is null) { return; // throw? } switch (propertyName) { case nameof(Command): if (Command is not null) { Command.PropertyChanged -= Command_PropertyChanged; } Command = new(model.Command, PageContext); Command.InitializeProperties(); // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. _itemTitle = model.Title; 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 is not null) { var newContextMenu = more .Select(item => { if (item is ICommandContextItem contextItem) { return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; } else { return new SeparatorContextItemViewModel() as IContextItemViewModel; } }) .ToList(); lock (MoreCommands) { ListHelpers.InPlaceUpdateList(MoreCommands, newContextMenu); } newContextMenu .OfType() .ToList() .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): // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. var model = _commandItemModel.Unsafe; if (model is not null) { _itemTitle = model.Title; } UpdateProperty(nameof(Title)); UpdateProperty(nameof(Name)); break; case nameof(Command.Icon): UpdateProperty(nameof(Icon)); break; } } protected override void UnsafeCleanup() { base.UnsafeCleanup(); lock (MoreCommands) { MoreCommands.OfType() .ToList() .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 is not 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, }