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 }