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 }