PageViewModel.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 CommunityToolkit.Mvvm.Input;
  8  using Microsoft.CmdPal.Core.Common.Helpers;
  9  using Microsoft.CmdPal.Core.ViewModels.Models;
 10  using Microsoft.CommandPalette.Extensions;
 11  
 12  namespace Microsoft.CmdPal.Core.ViewModels;
 13  
 14  public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
 15  {
 16      public TaskScheduler Scheduler { get; private set; }
 17  
 18      private readonly ExtensionObject<IPage> _pageModel;
 19  
 20      public bool IsLoading => ModelIsLoading || (!IsInitialized);
 21  
 22      [ObservableProperty]
 23      [NotifyPropertyChangedFor(nameof(IsLoading))]
 24      public virtual partial bool IsInitialized { get; protected set; }
 25  
 26      [ObservableProperty]
 27      public partial string ErrorMessage { get; protected set; } = string.Empty;
 28  
 29      [ObservableProperty]
 30      public partial bool IsNested { get; set; } = true;
 31  
 32      // This is set from the SearchBar
 33      [ObservableProperty]
 34      [NotifyPropertyChangedFor(nameof(ShowSuggestion))]
 35      public partial string SearchTextBox { get; set; } = string.Empty;
 36  
 37      [ObservableProperty]
 38      public virtual partial string PlaceholderText { get; private set; } = "Type here to search...";
 39  
 40      [ObservableProperty]
 41      [NotifyPropertyChangedFor(nameof(ShowSuggestion))]
 42      public virtual partial string TextToSuggest { get; protected set; } = string.Empty;
 43  
 44      public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox;
 45  
 46      [ObservableProperty]
 47      public partial AppExtensionHost ExtensionHost { get; private set; }
 48  
 49      public bool HasStatusMessage => MostRecentStatusMessage is not null;
 50  
 51      [ObservableProperty]
 52      [NotifyPropertyChangedFor(nameof(HasStatusMessage))]
 53      public partial StatusMessageViewModel? MostRecentStatusMessage { get; private set; } = null;
 54  
 55      public ObservableCollection<StatusMessageViewModel> StatusMessages => ExtensionHost.StatusMessages;
 56  
 57      // These are properties that are "observable" from the extension object
 58      // itself, in the sense that they get raised by PropChanged events from the
 59      // extension. However, we don't want to actually make them
 60      // [ObservableProperty]s, because PropChanged comes in off the UI thread,
 61      // and ObservableProperty is not smart enough to raise the PropertyChanged
 62      // on the UI thread.
 63      public string Name { get; protected set; } = string.Empty;
 64  
 65      public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty;
 66  
 67      public string Id { get; protected set; } = string.Empty;
 68  
 69      // This property maps to `IPage.IsLoading`, but we want to expose our own
 70      // `IsLoading` property as a combo of this value and `IsInitialized`
 71      public bool ModelIsLoading { get; protected set; } = true;
 72  
 73      public bool HasSearchBox { get; protected set; } = true;
 74  
 75      public bool HasFilters { get; protected set; }
 76  
 77      public IconInfoViewModel Icon { get; protected set; }
 78  
 79      public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost)
 80          : base(scheduler)
 81      {
 82          InitializeSelfAsPageContext();
 83          _pageModel = new(model);
 84          Scheduler = scheduler;
 85          ExtensionHost = extensionHost;
 86          Icon = new(null);
 87  
 88          ExtensionHost.StatusMessages.CollectionChanged += StatusMessages_CollectionChanged;
 89          UpdateHasStatusMessage();
 90      }
 91  
 92      private void StatusMessages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => UpdateHasStatusMessage();
 93  
 94      private void UpdateHasStatusMessage()
 95      {
 96          if (ExtensionHost.StatusMessages.Any())
 97          {
 98              var last = ExtensionHost.StatusMessages.Last();
 99              MostRecentStatusMessage = last;
100          }
101          else
102          {
103              MostRecentStatusMessage = null;
104          }
105      }
106  
107      //// Run on background thread from ListPage.xaml.cs
108      [RelayCommand]
109      internal Task<bool> InitializeAsync()
110      {
111          // TODO: We may want a SemaphoreSlim lock here.
112  
113          // TODO: We may want to investigate using some sort of AsyncEnumerable or populating these as they come into the UI layer
114          //       Though we have to think about threading here and circling back to the UI thread with a TaskScheduler.
115          try
116          {
117              InitializeProperties();
118          }
119          catch (Exception ex)
120          {
121              ShowException(ex, _pageModel?.Unsafe?.Name);
122              return Task.FromResult(false);
123          }
124  
125          // Notify we're done back on the UI Thread.
126          Task.Factory.StartNew(
127              () =>
128              {
129                  IsInitialized = true;
130  
131                  // TODO: Do we want an event/signal here that the Page Views can listen to? (i.e. ListPage setting the selected index to 0, however, in async world the user may have already started navigating around page...)
132              },
133              CancellationToken.None,
134              TaskCreationOptions.None,
135              Scheduler);
136          return Task.FromResult(true);
137      }
138  
139      public override void InitializeProperties()
140      {
141          var page = _pageModel.Unsafe;
142          if (page is null)
143          {
144              return; // throw?
145          }
146  
147          Id = page.Id;
148          Name = page.Name;
149          ModelIsLoading = page.IsLoading;
150          Title = page.Title;
151          Icon = new(page.Icon);
152          Icon.InitializeProperties();
153  
154          HasSearchBox = page is IListPage;
155  
156          // Let the UI know about our initial properties too.
157          UpdateProperty(nameof(Name));
158          UpdateProperty(nameof(Title));
159          UpdateProperty(nameof(ModelIsLoading));
160          UpdateProperty(nameof(IsLoading));
161          UpdateProperty(nameof(Icon));
162          UpdateProperty(nameof(HasSearchBox));
163  
164          page.PropChanged += Model_PropChanged;
165      }
166  
167      private void Model_PropChanged(object sender, IPropChangedEventArgs args)
168      {
169          try
170          {
171              var propName = args.PropertyName;
172              FetchProperty(propName);
173          }
174          catch (Exception ex)
175          {
176              ShowException(ex, _pageModel?.Unsafe?.Name);
177          }
178      }
179  
180      partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue);
181  
182      protected virtual void OnSearchTextBoxUpdated(string searchTextBox)
183      {
184          // The base page has no notion of data, so we do nothing here...
185          // subclasses should override.
186      }
187  
188      protected virtual void FetchProperty(string propertyName)
189      {
190          var model = this._pageModel.Unsafe;
191          if (model is null)
192          {
193              return; // throw?
194          }
195  
196          var updateProperty = true;
197          switch (propertyName)
198          {
199              case nameof(Name):
200                  this.Name = model.Name ?? string.Empty;
201                  UpdateProperty(nameof(Title));
202                  break;
203              case nameof(Title):
204                  this.Title = model.Title ?? string.Empty;
205                  break;
206              case nameof(IsLoading):
207                  this.ModelIsLoading = model.IsLoading;
208                  UpdateProperty(nameof(ModelIsLoading));
209                  break;
210              case nameof(Icon):
211                  this.Icon = new(model.Icon);
212                  break;
213              default:
214                  updateProperty = false;
215                  break;
216          }
217  
218          // GH #38829: If we always UpdateProperty here, then there's a possible
219          // race condition, where we raise the PropertyChanged(SearchText)
220          // before the subclass actually retrieves the new SearchText from the
221          // model. In that race situation, if the UI thread handles the
222          // PropertyChanged before ListViewModel fetches the SearchText, it'll
223          // think that the old search text is the _new_ value.
224          if (updateProperty)
225          {
226              UpdateProperty(propertyName);
227          }
228      }
229  
230      public new void ShowException(Exception ex, string? extensionHint = null)
231      {
232          // Set the extensionHint to the Page Title (if we have one, and one not provided).
233          // extensionHint ??= _pageModel?.Unsafe?.Title;
234          extensionHint ??= ExtensionHost.GetExtensionDisplayName() ?? Title;
235          Task.Factory.StartNew(
236              () =>
237              {
238                  var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint);
239                  ErrorMessage += message;
240              },
241              CancellationToken.None,
242              TaskCreationOptions.None,
243              Scheduler);
244      }
245  
246      public override string ToString() => $"{Title} ViewModel";
247  
248      protected override void UnsafeCleanup()
249      {
250          base.UnsafeCleanup();
251  
252          ExtensionHost.StatusMessages.CollectionChanged -= StatusMessages_CollectionChanged;
253  
254          var model = _pageModel.Unsafe;
255          if (model is not null)
256          {
257              model.PropChanged -= Model_PropChanged;
258          }
259      }
260  }
261  
262  public interface IPageContext
263  {
264      void ShowException(Exception ex, string? extensionHint = null);
265  
266      TaskScheduler Scheduler { get; }
267  }
268  
269  public interface IPageViewModelFactoryService
270  {
271      /// <summary>
272      /// Creates a new instance of the page view model for the given page type.
273      /// </summary>
274      /// <param name="page">The page for which to create the view model.</param>
275      /// <param name="nested">Indicates whether the page is not the top-level page.</param>
276      /// <param name="host">The command palette host that will host the page (for status messages)</param>
277      /// <returns>A new instance of the page view model.</returns>
278      PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host);
279  }