TopLevelViewModel.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.Collections.ObjectModel; 6 using CommunityToolkit.Mvvm.ComponentModel; 7 using ManagedCommon; 8 using Microsoft.CmdPal.Core.ViewModels; 9 using Microsoft.CmdPal.Core.ViewModels.Messages; 10 using Microsoft.CmdPal.UI.ViewModels.Settings; 11 using Microsoft.CommandPalette.Extensions; 12 using Microsoft.CommandPalette.Extensions.Toolkit; 13 using Microsoft.Extensions.DependencyInjection; 14 using Windows.Foundation; 15 using WyHash; 16 17 namespace Microsoft.CmdPal.UI.ViewModels; 18 19 public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider 20 { 21 private readonly SettingsModel _settings; 22 private readonly ProviderSettings _providerSettings; 23 private readonly IServiceProvider _serviceProvider; 24 private readonly CommandItemViewModel _commandItemViewModel; 25 26 private readonly string _commandProviderId; 27 28 private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id; 29 30 private string _fallbackId = string.Empty; 31 32 private string _generatedId = string.Empty; 33 34 private HotkeySettings? _hotkey; 35 private IIconInfo? _initialIcon; 36 37 private CommandAlias? Alias { get; set; } 38 39 public bool IsFallback { get; private set; } 40 41 [ObservableProperty] 42 public partial ObservableCollection<Tag> Tags { get; set; } = []; 43 44 public string Id => string.IsNullOrWhiteSpace(IdFromModel) ? _generatedId : IdFromModel; 45 46 public CommandPaletteHost ExtensionHost { get; private set; } 47 48 public CommandViewModel CommandViewModel => _commandItemViewModel.Command; 49 50 public CommandItemViewModel ItemViewModel => _commandItemViewModel; 51 52 public string CommandProviderId => _commandProviderId; 53 54 ////// ICommandItem 55 public string Title => _commandItemViewModel.Title; 56 57 public string Subtitle => _commandItemViewModel.Subtitle; 58 59 public IIconInfo Icon => _commandItemViewModel.Icon; 60 61 public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon; 62 63 ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe; 64 65 IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands 66 .Select(item => 67 { 68 if (item is ISeparatorContextItem) 69 { 70 return item as IContextItem; 71 } 72 else if (item is CommandContextItemViewModel commandItem) 73 { 74 return commandItem.Model.Unsafe; 75 } 76 else 77 { 78 return null; 79 } 80 }).ToArray(); 81 82 ////// IListItem 83 ITag[] IListItem.Tags => Tags.ToArray(); 84 85 IDetails? IListItem.Details => null; 86 87 string IListItem.Section => string.Empty; 88 89 string IListItem.TextToSuggest => string.Empty; 90 91 ////// INotifyPropChanged 92 public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; 93 94 // Fallback items 95 public string DisplayTitle { get; private set; } = string.Empty; 96 97 public HotkeySettings? Hotkey 98 { 99 get => _hotkey; 100 set 101 { 102 _serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(Id, value); 103 UpdateHotkey(); 104 UpdateTags(); 105 Save(); 106 } 107 } 108 109 public bool HasAlias => !string.IsNullOrEmpty(AliasText); 110 111 public string AliasText 112 { 113 get => Alias?.Alias ?? string.Empty; 114 set 115 { 116 var previousAlias = Alias?.Alias ?? string.Empty; 117 118 if (string.IsNullOrEmpty(value)) 119 { 120 Alias = null; 121 } 122 else 123 { 124 if (Alias is CommandAlias a) 125 { 126 a.Alias = value; 127 } 128 else 129 { 130 Alias = new CommandAlias(value, Id); 131 } 132 } 133 134 // Only call HandleChangeAlias if there was an actual change. 135 if (previousAlias != Alias?.Alias) 136 { 137 HandleChangeAlias(); 138 OnPropertyChanged(nameof(AliasText)); 139 OnPropertyChanged(nameof(IsDirectAlias)); 140 } 141 } 142 } 143 144 public bool IsDirectAlias 145 { 146 get => Alias?.IsDirect ?? false; 147 set 148 { 149 if (Alias is CommandAlias a) 150 { 151 a.IsDirect = value; 152 } 153 154 HandleChangeAlias(); 155 OnPropertyChanged(nameof(IsDirectAlias)); 156 } 157 } 158 159 public bool IsEnabled 160 { 161 get 162 { 163 if (IsFallback) 164 { 165 if (_providerSettings.FallbackCommands.TryGetValue(_fallbackId, out var fallbackSettings)) 166 { 167 return fallbackSettings.IsEnabled; 168 } 169 170 return true; 171 } 172 else 173 { 174 return _providerSettings.IsEnabled; 175 } 176 } 177 } 178 179 public TopLevelViewModel( 180 CommandItemViewModel item, 181 bool isFallback, 182 CommandPaletteHost extensionHost, 183 string commandProviderId, 184 SettingsModel settings, 185 ProviderSettings providerSettings, 186 IServiceProvider serviceProvider, 187 ICommandItem? commandItem) 188 { 189 _serviceProvider = serviceProvider; 190 _settings = settings; 191 _providerSettings = providerSettings; 192 _commandProviderId = commandProviderId; 193 _commandItemViewModel = item; 194 195 IsFallback = isFallback; 196 ExtensionHost = extensionHost; 197 if (isFallback && commandItem is FallbackCommandItem fallback) 198 { 199 _fallbackId = fallback.Id; 200 } 201 202 item.PropertyChangedBackground += Item_PropertyChanged; 203 204 // UpdateAlias(); 205 // UpdateHotkey(); 206 // UpdateTags(); 207 } 208 209 internal void InitializeProperties() 210 { 211 ItemViewModel.SlowInitializeProperties(); 212 213 if (IsFallback) 214 { 215 var model = _commandItemViewModel.Model.Unsafe; 216 217 // RPC to check type 218 if (model is IFallbackCommandItem fallback) 219 { 220 DisplayTitle = fallback.DisplayTitle; 221 } 222 223 UpdateInitialIcon(false); 224 } 225 } 226 227 private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) 228 { 229 if (!string.IsNullOrEmpty(e.PropertyName)) 230 { 231 PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName)); 232 233 if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command)) 234 { 235 GenerateId(); 236 237 FetchAliasFromAliasManager(); 238 UpdateHotkey(); 239 UpdateTags(); 240 UpdateInitialIcon(); 241 } 242 else if (e.PropertyName == nameof(CommandItem.Icon)) 243 { 244 UpdateInitialIcon(); 245 } 246 else if (e.PropertyName == nameof(CommandItem.DataPackage)) 247 { 248 DoOnUiThread(() => 249 { 250 OnPropertyChanged(nameof(CommandItem.DataPackage)); 251 }); 252 } 253 } 254 } 255 256 private void UpdateInitialIcon(bool raiseNotification = true) 257 { 258 if (_initialIcon != null || !_commandItemViewModel.Icon.IsSet) 259 { 260 return; 261 } 262 263 _initialIcon = _commandItemViewModel.Icon; 264 265 if (raiseNotification) 266 { 267 DoOnUiThread( 268 () => 269 { 270 PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(InitialIcon))); 271 }); 272 } 273 } 274 275 private void Save() => SettingsModel.SaveSettings(_settings); 276 277 private void HandleChangeAlias() 278 { 279 SetAlias(); 280 Save(); 281 } 282 283 public void SetAlias() 284 { 285 var commandAlias = Alias is null 286 ? null 287 : new CommandAlias(Alias.Alias, Alias.CommandId, Alias.IsDirect); 288 289 _serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, commandAlias); 290 UpdateTags(); 291 } 292 293 private void FetchAliasFromAliasManager() 294 { 295 var am = _serviceProvider.GetService<AliasManager>(); 296 if (am is not null) 297 { 298 var commandAlias = am.AliasFromId(Id); 299 if (commandAlias is not null) 300 { 301 // Decouple from the alias manager alias object 302 Alias = new CommandAlias(commandAlias.Alias, commandAlias.CommandId, commandAlias.IsDirect); 303 } 304 } 305 } 306 307 private void UpdateHotkey() 308 { 309 var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault(); 310 if (hotkey is not null) 311 { 312 _hotkey = hotkey.Hotkey; 313 } 314 } 315 316 private void UpdateTags() 317 { 318 List<Tag> tags = []; 319 320 if (Hotkey is not null) 321 { 322 tags.Add(new Tag() { Text = Hotkey.ToString() }); 323 } 324 325 if (Alias is not null) 326 { 327 tags.Add(new Tag() { Text = Alias.SearchPrefix }); 328 } 329 330 DoOnUiThread( 331 () => 332 { 333 ListHelpers.InPlaceUpdateList(Tags, tags); 334 PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Tags))); 335 }); 336 } 337 338 private void GenerateId() 339 { 340 // Use WyHash64 to generate stable ID hashes. 341 // manually seeding with 0, so that the hash is stable across launches 342 var result = WyHash64.ComputeHash64(_commandProviderId + DisplayTitle + Title + Subtitle, seed: 0); 343 _generatedId = $"{_commandProviderId}{result}"; 344 } 345 346 private void DoOnUiThread(Action action) 347 { 348 if (_commandItemViewModel.PageContext.TryGetTarget(out var pageContext)) 349 { 350 Task.Factory.StartNew( 351 action, 352 CancellationToken.None, 353 TaskCreationOptions.None, 354 pageContext.Scheduler); 355 } 356 } 357 358 internal bool SafeUpdateFallbackTextSynchronous(string newQuery) 359 { 360 if (!IsFallback) 361 { 362 return false; 363 } 364 365 if (!IsEnabled) 366 { 367 return false; 368 } 369 370 try 371 { 372 return UnsafeUpdateFallbackSynchronous(newQuery); 373 } 374 catch (Exception ex) 375 { 376 Logger.LogError(ex.ToString()); 377 } 378 379 return false; 380 } 381 382 /// <summary> 383 /// Calls UpdateQuery on our command, if we're a fallback item. This does 384 /// RPC work, so make sure you're calling it on a BG thread. 385 /// </summary> 386 /// <param name="newQuery">The new search text to pass to the extension</param> 387 /// <returns>true if our Title changed across this call</returns> 388 private bool UnsafeUpdateFallbackSynchronous(string newQuery) 389 { 390 var model = _commandItemViewModel.Model.Unsafe; 391 392 // RPC to check type 393 if (model is IFallbackCommandItem fallback) 394 { 395 var wasEmpty = string.IsNullOrEmpty(Title); 396 397 // RPC for method 398 fallback.FallbackHandler.UpdateQuery(newQuery); 399 var isEmpty = string.IsNullOrEmpty(Title); 400 return wasEmpty != isEmpty; 401 } 402 403 return false; 404 } 405 406 public PerformCommandMessage GetPerformCommandMessage() 407 { 408 return new PerformCommandMessage(this.CommandViewModel.Model, new Core.ViewModels.Models.ExtensionObject<IListItem>(this)); 409 } 410 411 public override string ToString() 412 { 413 return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}"; 414 } 415 416 public IDictionary<string, object?> GetProperties() 417 { 418 return new Dictionary<string, object?> 419 { 420 [WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage, 421 }; 422 } 423 }