/ src / modules / cmdpal / Microsoft.CmdPal.UI.ViewModels / TopLevelViewModel.cs
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  }