/ src / modules / cmdpal / Core / Microsoft.CmdPal.Core.ViewModels / CommandItemViewModel.cs
CommandItemViewModel.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System.Diagnostics.CodeAnalysis;
  6  using Microsoft.CmdPal.Core.Common;
  7  using Microsoft.CmdPal.Core.ViewModels.Messages;
  8  using Microsoft.CmdPal.Core.ViewModels.Models;
  9  using Microsoft.CommandPalette.Extensions;
 10  using Microsoft.CommandPalette.Extensions.Toolkit;
 11  using Windows.ApplicationModel.DataTransfer;
 12  
 13  namespace Microsoft.CmdPal.Core.ViewModels;
 14  
 15  [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
 16  public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext
 17  {
 18      public ExtensionObject<ICommandItem> Model => _commandItemModel;
 19  
 20      private ExtensionObject<IExtendedAttributesProvider>? ExtendedAttributesProvider { get; set; }
 21  
 22      private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
 23      private CommandContextItemViewModel? _defaultCommandContextItemViewModel;
 24  
 25      internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
 26  
 27      protected bool IsFastInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.FastInitialized);
 28  
 29      protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
 30  
 31      protected bool IsSelectedInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.SelectionInitialized);
 32  
 33      public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
 34  
 35      // These are properties that are "observable" from the extension object
 36      // itself, in the sense that they get raised by PropChanged events from the
 37      // extension. However, we don't want to actually make them
 38      // [ObservableProperty]s, because PropChanged comes in off the UI thread,
 39      // and ObservableProperty is not smart enough to raise the PropertyChanged
 40      // on the UI thread.
 41      public string Name => Command.Name;
 42  
 43      private string _itemTitle = string.Empty;
 44  
 45      public string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
 46  
 47      public string Subtitle { get; private set; } = string.Empty;
 48  
 49      private IconInfoViewModel _icon = new(null);
 50  
 51      public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon;
 52  
 53      public CommandViewModel Command { get; private set; }
 54  
 55      public List<IContextItemViewModel> MoreCommands { get; private set; } = [];
 56  
 57      IEnumerable<IContextItemViewModel> IContextMenuContext.MoreCommands => MoreCommands;
 58  
 59      private List<CommandContextItemViewModel> ActualCommands => MoreCommands.OfType<CommandContextItemViewModel>().ToList();
 60  
 61      public bool HasMoreCommands => ActualCommands.Count > 0;
 62  
 63      public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
 64  
 65      public CommandItemViewModel? PrimaryCommand => this;
 66  
 67      public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null;
 68  
 69      public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
 70  
 71      public DataPackageView? DataPackage { get; private set; }
 72  
 73      public List<IContextItemViewModel> AllCommands
 74      {
 75          get
 76          {
 77              List<IContextItemViewModel> l = _defaultCommandContextItemViewModel is null ?
 78                  new() :
 79                  [_defaultCommandContextItemViewModel];
 80  
 81              l.AddRange(MoreCommands);
 82              return l;
 83          }
 84      }
 85  
 86      private static readonly IconInfoViewModel _errorIcon;
 87  
 88      static CommandItemViewModel()
 89      {
 90          _errorIcon = new(new IconInfo("\uEA39")); // ErrorBadge
 91          _errorIcon.InitializeProperties();
 92      }
 93  
 94      public CommandItemViewModel(ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext)
 95          : base(errorContext)
 96      {
 97          _commandItemModel = item;
 98          Command = new(null, errorContext);
 99      }
100  
101      public void FastInitializeProperties()
102      {
103          if (IsFastInitialized)
104          {
105              return;
106          }
107  
108          var model = _commandItemModel.Unsafe;
109          if (model is null)
110          {
111              return;
112          }
113  
114          Command = new(model.Command, PageContext);
115          Command.FastInitializeProperties();
116  
117          _itemTitle = model.Title;
118          Subtitle = model.Subtitle;
119  
120          Initialized |= InitializedState.FastInitialized;
121      }
122  
123      //// Called from ListViewModel on background thread started in ListPage.xaml.cs
124      public override void InitializeProperties()
125      {
126          if (IsInitialized)
127          {
128              return;
129          }
130  
131          if (!IsFastInitialized)
132          {
133              FastInitializeProperties();
134          }
135  
136          var model = _commandItemModel.Unsafe;
137          if (model is null)
138          {
139              return;
140          }
141  
142          Command.InitializeProperties();
143  
144          var icon = model.Icon;
145          if (icon is not null)
146          {
147              _icon = new(icon);
148              _icon.InitializeProperties();
149          }
150  
151          // TODO: Do these need to go into FastInit?
152          model.PropChanged += Model_PropChanged;
153          Command.PropertyChanged += Command_PropertyChanged;
154  
155          UpdateProperty(nameof(Name));
156          UpdateProperty(nameof(Title));
157          UpdateProperty(nameof(Subtitle));
158          UpdateProperty(nameof(Icon));
159  
160          // Load-bearing: if you don't raise a IsInitialized here, then
161          // TopLevelViewModel will never know what the command's ID is, so it
162          // will never be able to load Hotkeys & aliases
163          UpdateProperty(nameof(IsInitialized));
164  
165          if (model is IExtendedAttributesProvider extendedAttributesProvider)
166          {
167              ExtendedAttributesProvider = new ExtensionObject<IExtendedAttributesProvider>(extendedAttributesProvider);
168              var properties = extendedAttributesProvider.GetProperties();
169              UpdateDataPackage(properties);
170          }
171  
172          Initialized |= InitializedState.Initialized;
173      }
174  
175      public virtual void SlowInitializeProperties()
176      {
177          if (IsSelectedInitialized)
178          {
179              return;
180          }
181  
182          if (!IsInitialized)
183          {
184              InitializeProperties();
185          }
186  
187          var model = _commandItemModel.Unsafe;
188          if (model is null)
189          {
190              return;
191          }
192  
193          var more = model.MoreCommands;
194          if (more is not null)
195          {
196              MoreCommands = more
197                  .Select<IContextItem, IContextItemViewModel>(item =>
198                  {
199                      return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel();
200                  })
201                  .ToList();
202          }
203  
204          // Here, we're already theoretically in the async context, so we can
205          // use Initialize straight up
206          MoreCommands
207              .OfType<CommandContextItemViewModel>()
208              .ToList()
209              .ForEach(contextItem =>
210              {
211                  contextItem.SlowInitializeProperties();
212              });
213  
214          if (!string.IsNullOrEmpty(model.Command?.Name))
215          {
216              _defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
217              {
218                  _itemTitle = Name,
219                  Subtitle = Subtitle,
220                  Command = Command,
221  
222                  // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever
223                  // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel.
224              };
225  
226              // Only set the icon on the context item for us if our command didn't
227              // have its own icon
228              UpdateDefaultContextItemIcon();
229          }
230  
231          Initialized |= InitializedState.SelectionInitialized;
232          UpdateProperty(nameof(MoreCommands));
233          UpdateProperty(nameof(AllCommands));
234          UpdateProperty(nameof(IsSelectedInitialized));
235      }
236  
237      public bool SafeFastInit()
238      {
239          try
240          {
241              FastInitializeProperties();
242              return true;
243          }
244          catch (Exception ex)
245          {
246              CoreLogger.LogError("error fast initializing CommandItemViewModel", ex);
247              Command = new(null, PageContext);
248              _itemTitle = "Error";
249              Subtitle = "Item failed to load";
250              MoreCommands = [];
251              _icon = _errorIcon;
252              Initialized |= InitializedState.Error;
253          }
254  
255          return false;
256      }
257  
258      public bool SafeSlowInit()
259      {
260          try
261          {
262              SlowInitializeProperties();
263              return true;
264          }
265          catch (Exception ex)
266          {
267              Initialized |= InitializedState.Error;
268              CoreLogger.LogError("error slow initializing CommandItemViewModel", ex);
269          }
270  
271          return false;
272      }
273  
274      public bool SafeInitializeProperties()
275      {
276          try
277          {
278              InitializeProperties();
279              return true;
280          }
281          catch (Exception ex)
282          {
283              CoreLogger.LogError("error initializing CommandItemViewModel", ex);
284              Command = new(null, PageContext);
285              _itemTitle = "Error";
286              Subtitle = "Item failed to load";
287              MoreCommands = [];
288              _icon = _errorIcon;
289              Initialized |= InitializedState.Error;
290          }
291  
292          return false;
293      }
294  
295      private void Model_PropChanged(object sender, IPropChangedEventArgs args)
296      {
297          try
298          {
299              FetchProperty(args.PropertyName);
300          }
301          catch (Exception ex)
302          {
303              ShowException(ex, _commandItemModel?.Unsafe?.Title);
304          }
305      }
306  
307      protected virtual void FetchProperty(string propertyName)
308      {
309          var model = this._commandItemModel.Unsafe;
310          if (model is null)
311          {
312              return; // throw?
313          }
314  
315          switch (propertyName)
316          {
317              case nameof(Command):
318                  Command.PropertyChanged -= Command_PropertyChanged;
319                  Command = new(model.Command, PageContext);
320                  Command.InitializeProperties();
321                  Command.PropertyChanged += Command_PropertyChanged;
322  
323                  // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
324                  // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
325                  _itemTitle = model.Title;
326  
327                  _defaultCommandContextItemViewModel?.Command = Command;
328                  _defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle);
329                  UpdateDefaultContextItemIcon();
330  
331                  UpdateProperty(nameof(Name));
332                  UpdateProperty(nameof(Title));
333                  UpdateProperty(nameof(Icon));
334                  break;
335  
336              case nameof(Title):
337                  _itemTitle = model.Title;
338                  break;
339  
340              case nameof(Subtitle):
341                  var modelSubtitle = model.Subtitle;
342                  this.Subtitle = modelSubtitle;
343                  _defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
344                  break;
345  
346              case nameof(Icon):
347                  var oldIcon = _icon;
348                  _icon = new(model.Icon);
349                  _icon.InitializeProperties();
350                  if (oldIcon.IsSet || _icon.IsSet)
351                  {
352                      UpdateProperty(nameof(Icon));
353                  }
354  
355                  UpdateDefaultContextItemIcon();
356  
357                  break;
358  
359              case nameof(model.MoreCommands):
360                  var more = model.MoreCommands;
361                  if (more is not null)
362                  {
363                      var newContextMenu = more
364                          .Select<IContextItem, IContextItemViewModel>(item =>
365                          {
366                              return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel();
367                          })
368                          .ToList();
369                      lock (MoreCommands)
370                      {
371                          ListHelpers.InPlaceUpdateList(MoreCommands, newContextMenu);
372                      }
373  
374                      newContextMenu
375                          .OfType<CommandContextItemViewModel>()
376                          .ToList()
377                          .ForEach(contextItem =>
378                          {
379                              contextItem.InitializeProperties();
380                          });
381                  }
382                  else
383                  {
384                      lock (MoreCommands)
385                      {
386                          MoreCommands.Clear();
387                      }
388                  }
389  
390                  UpdateProperty(nameof(SecondaryCommand));
391                  UpdateProperty(nameof(SecondaryCommandName));
392                  UpdateProperty(nameof(HasMoreCommands));
393  
394                  break;
395              case nameof(DataPackage):
396                  UpdateDataPackage(ExtendedAttributesProvider?.Unsafe?.GetProperties());
397                  break;
398          }
399  
400          UpdateProperty(propertyName);
401      }
402  
403      private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
404      {
405          var propertyName = e.PropertyName;
406          var model = _commandItemModel.Unsafe;
407          if (model is null)
408          {
409              return;
410          }
411  
412          switch (propertyName)
413          {
414              case nameof(Command.Name):
415                  // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
416                  // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
417                  _itemTitle = model.Title;
418                  UpdateProperty(nameof(Title), nameof(Name));
419  
420                  _defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name);
421                  break;
422  
423              case nameof(Command.Icon):
424                  UpdateDefaultContextItemIcon();
425                  UpdateProperty(nameof(Icon));
426                  break;
427          }
428      }
429  
430      private void UpdateDefaultContextItemIcon()
431      {
432          // Command icon takes precedence over our icon on the primary command
433          _defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
434      }
435  
436      private void UpdateTitle(string? title)
437      {
438          _itemTitle = title ?? string.Empty;
439          UpdateProperty(nameof(Title));
440      }
441  
442      private void UpdateIcon(IIconInfo? iconInfo)
443      {
444          _icon = new(iconInfo);
445          _icon.InitializeProperties();
446          UpdateProperty(nameof(Icon));
447      }
448  
449      private void UpdateDataPackage(IDictionary<string, object?>? properties)
450      {
451          DataPackage =
452              properties?.TryGetValue(WellKnownExtensionAttributes.DataPackage, out var dataPackageView) == true &&
453              dataPackageView is DataPackageView view
454                  ? view
455                  : null;
456          UpdateProperty(nameof(DataPackage));
457      }
458  
459      protected override void UnsafeCleanup()
460      {
461          base.UnsafeCleanup();
462  
463          lock (MoreCommands)
464          {
465              MoreCommands.OfType<CommandContextItemViewModel>()
466                          .ToList()
467                          .ForEach(c => c.SafeCleanup());
468              MoreCommands.Clear();
469          }
470  
471          // _listItemIcon.SafeCleanup();
472          _icon = new(null); // necessary?
473  
474          _defaultCommandContextItemViewModel?.SafeCleanup();
475          _defaultCommandContextItemViewModel = null;
476  
477          Command.PropertyChanged -= Command_PropertyChanged;
478          Command.SafeCleanup();
479  
480          var model = _commandItemModel.Unsafe;
481          if (model is not null)
482          {
483              model.PropChanged -= Model_PropChanged;
484          }
485      }
486  
487      public override void SafeCleanup()
488      {
489          base.SafeCleanup();
490          Initialized |= InitializedState.CleanedUp;
491      }
492  }
493  
494  [Flags]
495  internal enum InitializedState
496  {
497      Uninitialized = 0,
498      FastInitialized = 1,
499      Initialized = 2,
500      SelectionInitialized = 4,
501      Error = 8,
502      CleanedUp = 16,
503  }