ShellViewModel.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.ComponentModel;
  6  using CommunityToolkit.Mvvm.ComponentModel;
  7  using CommunityToolkit.Mvvm.Input;
  8  using CommunityToolkit.Mvvm.Messaging;
  9  using Microsoft.CmdPal.Core.Common;
 10  using Microsoft.CmdPal.Core.ViewModels.Messages;
 11  using Microsoft.CmdPal.Core.ViewModels.Models;
 12  using Microsoft.CommandPalette.Extensions;
 13  
 14  namespace Microsoft.CmdPal.Core.ViewModels;
 15  
 16  public partial class ShellViewModel : ObservableObject,
 17      IDisposable,
 18      IRecipient<PerformCommandMessage>,
 19      IRecipient<HandleCommandResultMessage>
 20  {
 21      private readonly IRootPageService _rootPageService;
 22      private readonly IAppHostService _appHostService;
 23      private readonly TaskScheduler _scheduler;
 24      private readonly IPageViewModelFactoryService _pageViewModelFactory;
 25      private readonly Lock _invokeLock = new();
 26      private Task? _handleInvokeTask;
 27  
 28      // Cancellation token source for page loading/navigation operations
 29      private CancellationTokenSource? _navigationCts;
 30  
 31      [ObservableProperty]
 32      public partial bool IsLoaded { get; set; } = false;
 33  
 34      [ObservableProperty]
 35      public partial DetailsViewModel? Details { get; set; }
 36  
 37      [ObservableProperty]
 38      public partial bool IsDetailsVisible { get; set; }
 39  
 40      [ObservableProperty]
 41      public partial bool IsSearchBoxVisible { get; set; } = true;
 42  
 43      private PageViewModel _currentPage;
 44  
 45      public PageViewModel CurrentPage
 46      {
 47          get => _currentPage;
 48          set
 49          {
 50              var oldValue = _currentPage;
 51              if (SetProperty(ref _currentPage, value))
 52              {
 53                  oldValue.PropertyChanged -= CurrentPage_PropertyChanged;
 54                  value.PropertyChanged += CurrentPage_PropertyChanged;
 55  
 56                  if (oldValue is IDisposable disposable)
 57                  {
 58                      try
 59                      {
 60                          disposable.Dispose();
 61                      }
 62                      catch (Exception ex)
 63                      {
 64                          CoreLogger.LogError(ex.ToString());
 65                      }
 66                  }
 67              }
 68          }
 69      }
 70  
 71      private void CurrentPage_PropertyChanged(object? sender, PropertyChangedEventArgs e)
 72      {
 73          if (e.PropertyName == nameof(PageViewModel.HasSearchBox))
 74          {
 75              IsSearchBoxVisible = CurrentPage.HasSearchBox;
 76          }
 77      }
 78  
 79      private IPage? _rootPage;
 80  
 81      private bool _isNested;
 82  
 83      public bool IsNested => _isNested;
 84  
 85      public PageViewModel NullPage { get; private set; }
 86  
 87      public ShellViewModel(
 88          TaskScheduler scheduler,
 89          IRootPageService rootPageService,
 90          IPageViewModelFactoryService pageViewModelFactory,
 91          IAppHostService appHostService)
 92      {
 93          _pageViewModelFactory = pageViewModelFactory;
 94          _scheduler = scheduler;
 95          _rootPageService = rootPageService;
 96          _appHostService = appHostService;
 97  
 98          NullPage = new NullPageViewModel(_scheduler, appHostService.GetDefaultHost());
 99          _currentPage = new LoadingPageViewModel(null, _scheduler, appHostService.GetDefaultHost());
100  
101          // Register to receive messages
102          WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
103          WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
104      }
105  
106      [RelayCommand]
107      public async Task<bool> LoadAsync()
108      {
109          // First, do any loading that the root page service needs to do before we can
110          // display the root page. For example, this might include loading
111          // the built-in commands, or loading the settings.
112          await _rootPageService.PreLoadAsync();
113  
114          IsLoaded = true;
115  
116          // Now that the basics are set up, we can load the root page.
117          _rootPage = _rootPageService.GetRootPage();
118  
119          // This sends a message to us to load the root page view model.
120          WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
121  
122          // Now that the root page is loaded, do any post-load work that the root page service needs to do.
123          // This runs asynchronously, on a background thread.
124          // This might include starting extensions, for example.
125          // Note: We don't await this, so that we can return immediately.
126          // This is important because we don't want to block the UI thread.
127          _ = Task.Run(async () =>
128          {
129              await _rootPageService.PostLoadRootPageAsync();
130          });
131  
132          return true;
133      }
134  
135      private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default)
136      {
137          // Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems.
138          // IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar
139          // This triggers that load generally with the InitializeCommand asynchronously when we navigate to a page.
140          // We could re-track the page loading status, if we need it more granularly below again, but between the initialized and error message, we can infer some status.
141          // We could also maybe move this thread offloading we do for loading into PageViewModel and better communicate between the two... a few different options.
142  
143          ////LoadedState = ViewModelLoadedState.Loading;
144          if (!viewModel.IsInitialized
145              && viewModel.InitializeCommand is not null)
146          {
147              var outer = Task.Run(
148                  async () =>
149                  {
150                      // You know, this creates the situation where we wait for
151                      // both loading page properties, AND the items, before we
152                      // display anything.
153                      //
154                      // We almost need to do an async await on initialize, then
155                      // just a fire-and-forget on FetchItems.
156                      // RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
157                      // Definitely some more clean-up to do, but at least its centralized to one spot now.
158                      viewModel.InitializeCommand.Execute(null);
159  
160                      await viewModel.InitializeCommand.ExecutionTask!;
161  
162                      if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
163                      {
164                          if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
165                          {
166                              CoreLogger.LogError(ex.ToString());
167                          }
168                      }
169                      else
170                      {
171                          var t = Task.Factory.StartNew(
172                              () =>
173                              {
174                                  if (cancellationToken.IsCancellationRequested)
175                                  {
176                                      if (viewModel is IDisposable disposable)
177                                      {
178                                          try
179                                          {
180                                              disposable.Dispose();
181                                          }
182                                          catch (Exception ex)
183                                          {
184                                              CoreLogger.LogError(ex.ToString());
185                                          }
186                                      }
187  
188                                      return;
189                                  }
190  
191                                  CurrentPage = viewModel;
192                              },
193                              cancellationToken,
194                              TaskCreationOptions.None,
195                              _scheduler);
196                          await t;
197                      }
198                  },
199                  cancellationToken);
200              await outer;
201          }
202          else
203          {
204              if (cancellationToken.IsCancellationRequested)
205              {
206                  if (viewModel is IDisposable disposable)
207                  {
208                      try
209                      {
210                          disposable.Dispose();
211                      }
212                      catch (Exception ex)
213                      {
214                          CoreLogger.LogError(ex.ToString());
215                      }
216                  }
217  
218                  return;
219              }
220  
221              CurrentPage = viewModel;
222          }
223      }
224  
225      public void Receive(PerformCommandMessage message)
226      {
227          PerformCommand(message);
228      }
229  
230      private void PerformCommand(PerformCommandMessage message)
231      {
232          // Create/replace the navigation cancellation token.
233          // If one already exists, cancel and dispose it first.
234          var newCts = new CancellationTokenSource();
235          var oldCts = Interlocked.Exchange(ref _navigationCts, newCts);
236          if (oldCts is not null)
237          {
238              try
239              {
240                  oldCts.Cancel();
241              }
242              catch (Exception ex)
243              {
244                  CoreLogger.LogError(ex.ToString());
245              }
246              finally
247              {
248                  oldCts.Dispose();
249              }
250          }
251  
252          var navigationToken = newCts.Token;
253  
254          var command = message.Command.Unsafe;
255          if (command is null)
256          {
257              return;
258          }
259  
260          var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
261  
262          _rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host);
263  
264          try
265          {
266              if (command is IPage page)
267              {
268                  CoreLogger.LogDebug($"Navigating to page");
269  
270                  var isMainPage = command == _rootPage;
271                  _isNested = !isMainPage;
272  
273                  // Telemetry: Track extension page navigation for session metrics
274                  if (host is not null)
275                  {
276                      string extensionId = host.GetExtensionDisplayName() ?? "builtin";
277                      string commandId = command?.Id ?? "unknown";
278                      string commandName = command?.Name ?? "unknown";
279                      WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
280                          new(extensionId, commandId, commandName, true, 0));
281                  }
282  
283                  // Construct our ViewModel of the appropriate type and pass it the UI Thread context.
284                  var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!);
285                  if (pageViewModel is null)
286                  {
287                      CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}");
288                      throw new NotSupportedException();
289                  }
290  
291                  // Clear command bar, ViewModel initialization can already set new commands if it wants to
292                  OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
293  
294                  // Kick off async loading of our ViewModel
295                  LoadPageViewModelAsync(pageViewModel, navigationToken)
296                      .ContinueWith(
297                          (Task t) =>
298                          {
299                              // clean up the navigation token if it's still ours
300                              if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts)
301                              {
302                                  newCts.Dispose();
303                              }
304                          },
305                          navigationToken,
306                          TaskContinuationOptions.None,
307                          _scheduler);
308  
309                  // While we're loading in the background, immediately move to the next page.
310                  WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
311  
312                  // Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
313                  // See RootFrame_Navigated event handler.
314              }
315              else if (command is IInvokableCommand invokable)
316              {
317                  CoreLogger.LogDebug($"Invoking command");
318  
319                  WeakReferenceMessenger.Default.Send<TelemetryBeginInvokeMessage>();
320                  StartInvoke(message, invokable, host);
321              }
322          }
323          catch (Exception ex)
324          {
325              // TODO: It would be better to do this as a page exception, rather
326              // than a silent log message.
327              host?.Log(ex.Message);
328          }
329      }
330  
331      private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
332      {
333          // TODO GH #525 This needs more better locking.
334          lock (_invokeLock)
335          {
336              if (_handleInvokeTask is not null)
337              {
338                  // do nothing - a command is already doing a thing
339              }
340              else
341              {
342                  _handleInvokeTask = Task.Run(() =>
343                  {
344                      SafeHandleInvokeCommandSynchronous(message, invokable, host);
345                  });
346              }
347          }
348      }
349  
350      private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
351      {
352          // Telemetry: Track command execution time and success
353          var stopwatch = System.Diagnostics.Stopwatch.StartNew();
354          var command = message.Command.Unsafe;
355          string extensionId = host?.GetExtensionDisplayName() ?? "builtin";
356          string commandId = command?.Id ?? "unknown";
357          string commandName = command?.Name ?? "unknown";
358          bool success = false;
359  
360          try
361          {
362              // Call out to extension process.
363              // * May fail!
364              // * May never return!
365              var result = invokable.Invoke(message.Context);
366  
367              // But if it did succeed, we need to handle the result.
368              UnsafeHandleCommandResult(result);
369  
370              success = true;
371              _handleInvokeTask = null;
372          }
373          catch (Exception ex)
374          {
375              success = false;
376              _handleInvokeTask = null;
377  
378              // Telemetry: Track errors for session metrics
379              WeakReferenceMessenger.Default.Send<ErrorOccurredMessage>(new());
380  
381              // TODO: It would be better to do this as a page exception, rather
382              // than a silent log message.
383              host?.Log(ex.Message);
384          }
385          finally
386          {
387              // Telemetry: Send extension invocation metrics (always sent, even on failure)
388              stopwatch.Stop();
389              WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
390                  new(extensionId, commandId, commandName, success, (ulong)stopwatch.ElapsedMilliseconds));
391          }
392      }
393  
394      private void UnsafeHandleCommandResult(ICommandResult? result)
395      {
396          if (result is null)
397          {
398              // No result, nothing to do.
399              return;
400          }
401  
402          var kind = result.Kind;
403          CoreLogger.LogDebug($"handling {kind.ToString()}");
404  
405          WeakReferenceMessenger.Default.Send<TelemetryInvokeResultMessage>(new(kind));
406          switch (kind)
407          {
408              case CommandResultKind.Dismiss:
409                  {
410                      // Reset the palette to the main page and dismiss
411                      GoHome(withAnimation: false, focusSearch: false);
412                      WeakReferenceMessenger.Default.Send(new DismissMessage());
413                      break;
414                  }
415  
416              case CommandResultKind.GoHome:
417                  {
418                      // Go back to the main page, but keep it open
419                      GoHome();
420                      break;
421                  }
422  
423              case CommandResultKind.GoBack:
424                  {
425                      GoBack();
426                      break;
427                  }
428  
429              case CommandResultKind.Hide:
430                  {
431                      // Keep this page open, but hide the palette.
432                      WeakReferenceMessenger.Default.Send(new DismissMessage());
433                      break;
434                  }
435  
436              case CommandResultKind.KeepOpen:
437                  {
438                      // Do nothing.
439                      break;
440                  }
441  
442              case CommandResultKind.Confirm:
443                  {
444                      if (result.Args is IConfirmationArgs a)
445                      {
446                          WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a));
447                      }
448  
449                      break;
450                  }
451  
452              case CommandResultKind.ShowToast:
453                  {
454                      if (result.Args is IToastArgs a)
455                      {
456                          WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
457                          UnsafeHandleCommandResult(a.Result);
458                      }
459  
460                      break;
461                  }
462          }
463      }
464  
465      public void GoHome(bool withAnimation = true, bool focusSearch = true)
466      {
467          _rootPageService.GoHome();
468          WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(withAnimation, focusSearch));
469      }
470  
471      public void GoBack(bool withAnimation = true, bool focusSearch = true)
472      {
473          WeakReferenceMessenger.Default.Send<GoBackMessage>(new(withAnimation, focusSearch));
474      }
475  
476      public void Receive(HandleCommandResultMessage message)
477      {
478          UnsafeHandleCommandResult(message.Result.Unsafe);
479      }
480  
481      private void OnUIThread(Action action)
482      {
483          _ = Task.Factory.StartNew(
484              action,
485              CancellationToken.None,
486              TaskCreationOptions.None,
487              _scheduler);
488      }
489  
490      public void CancelNavigation()
491      {
492          _navigationCts?.Cancel();
493      }
494  
495      public void Dispose()
496      {
497          _handleInvokeTask?.Dispose();
498          _navigationCts?.Dispose();
499  
500          GC.SuppressFinalize(this);
501      }
502  }