ContentPageViewModel.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 System.Diagnostics.CodeAnalysis; 7 using CommunityToolkit.Mvvm.ComponentModel; 8 using CommunityToolkit.Mvvm.Input; 9 using CommunityToolkit.Mvvm.Messaging; 10 using Microsoft.CmdPal.Core.ViewModels.Messages; 11 using Microsoft.CmdPal.Core.ViewModels.Models; 12 using Microsoft.CommandPalette.Extensions; 13 using Microsoft.CommandPalette.Extensions.Toolkit; 14 15 namespace Microsoft.CmdPal.Core.ViewModels; 16 17 public partial class ContentPageViewModel : PageViewModel, ICommandBarContext 18 { 19 private readonly ExtensionObject<IContentPage> _model; 20 21 [ObservableProperty] 22 public partial ObservableCollection<ContentViewModel> Content { get; set; } = []; 23 24 public List<IContextItemViewModel> Commands { get; private set; } = []; 25 26 public bool HasCommands => ActualCommands.Count > 0; 27 28 public DetailsViewModel? Details { get; private set; } 29 30 [MemberNotNullWhen(true, nameof(Details))] 31 public bool HasDetails => Details is not null; 32 33 /////// ICommandBarContext /////// 34 public IEnumerable<IContextItemViewModel> MoreCommands => Commands.Skip(1); 35 36 private List<CommandContextItemViewModel> ActualCommands => Commands.OfType<CommandContextItemViewModel>().ToList(); 37 38 public bool HasMoreCommands => ActualCommands.Count > 1; 39 40 public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; 41 42 public CommandItemViewModel? PrimaryCommand => HasCommands ? ActualCommands[0] : null; 43 44 public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[1] : null; 45 46 public List<IContextItemViewModel> AllCommands => Commands; 47 48 // Remember - "observable" properties from the model (via PropChanged) 49 // cannot be marked [ObservableProperty] 50 public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host) 51 : base(model, scheduler, host) 52 { 53 _model = new(model); 54 } 55 56 // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? 57 private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchContent(); 58 59 //// Run on background thread, from InitializeAsync or Model_ItemsChanged 60 private void FetchContent() 61 { 62 List<ContentViewModel> newContent = []; 63 try 64 { 65 var newItems = _model.Unsafe!.GetContent(); 66 67 foreach (var item in newItems) 68 { 69 var viewModel = ViewModelFromContent(item, PageContext); 70 if (viewModel is not null) 71 { 72 viewModel.InitializeProperties(); 73 newContent.Add(viewModel); 74 } 75 } 76 } 77 catch (Exception ex) 78 { 79 ShowException(ex, _model?.Unsafe?.Name); 80 throw; 81 } 82 83 var oneContent = newContent.Count == 1; 84 newContent.ForEach(c => c.OnlyControlOnPage = oneContent); 85 86 // Now, back to a UI thread to update the observable collection 87 DoOnUiThread( 88 () => 89 { 90 ListHelpers.InPlaceUpdateList(Content, newContent); 91 }); 92 } 93 94 public virtual ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context) 95 { 96 // The core ContentPageViewModel doesn't actually handle any content, 97 // so we just return null here. 98 // The real content is handled by the derived class CommandPaletteContentPageViewModel 99 return null; 100 } 101 102 public override void InitializeProperties() 103 { 104 base.InitializeProperties(); 105 106 var model = _model.Unsafe; 107 if (model is null) 108 { 109 return; // throw? 110 } 111 112 Commands = model.Commands 113 .ToList() 114 .Select<IContextItem, IContextItemViewModel>(item => 115 { 116 if (item is ICommandContextItem contextItem) 117 { 118 return new CommandContextItemViewModel(contextItem, PageContext); 119 } 120 else 121 { 122 return new SeparatorViewModel(); 123 } 124 }) 125 .ToList(); 126 127 Commands 128 .OfType<CommandContextItemViewModel>() 129 .ToList() 130 .ForEach(contextItem => 131 { 132 contextItem.InitializeProperties(); 133 }); 134 135 var extensionDetails = model.Details; 136 if (extensionDetails is not null) 137 { 138 Details = new(extensionDetails, PageContext); 139 Details.InitializeProperties(); 140 } 141 142 UpdateDetails(); 143 144 FetchContent(); 145 model.ItemsChanged += Model_ItemsChanged; 146 147 DoOnUiThread( 148 () => 149 { 150 WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this)); 151 }); 152 } 153 154 protected override void FetchProperty(string propertyName) 155 { 156 base.FetchProperty(propertyName); 157 158 var model = this._model.Unsafe; 159 if (model is null) 160 { 161 return; // throw? 162 } 163 164 switch (propertyName) 165 { 166 case nameof(Commands): 167 168 var more = model.Commands; 169 if (more is not null) 170 { 171 var newContextMenu = more 172 .ToList() 173 .Select(item => 174 { 175 if (item is ICommandContextItem contextItem) 176 { 177 return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; 178 } 179 else 180 { 181 return new SeparatorViewModel(); 182 } 183 }) 184 .ToList(); 185 186 lock (Commands) 187 { 188 ListHelpers.InPlaceUpdateList(Commands, newContextMenu); 189 } 190 191 Commands 192 .OfType<CommandContextItemViewModel>() 193 .ToList() 194 .ForEach(contextItem => 195 { 196 contextItem.SlowInitializeProperties(); 197 }); 198 } 199 else 200 { 201 Commands.Clear(); 202 } 203 204 UpdateProperty(nameof(PrimaryCommand)); 205 UpdateProperty(nameof(SecondaryCommand)); 206 UpdateProperty(nameof(SecondaryCommandName)); 207 UpdateProperty(nameof(HasCommands)); 208 UpdateProperty(nameof(HasMoreCommands)); 209 UpdateProperty(nameof(AllCommands)); 210 DoOnUiThread( 211 () => 212 { 213 WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this)); 214 }); 215 216 break; 217 case nameof(Details): 218 var extensionDetails = model.Details; 219 Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null; 220 UpdateDetails(); 221 break; 222 } 223 224 UpdateProperty(propertyName); 225 } 226 227 private void UpdateDetails() 228 { 229 UpdateProperty(nameof(Details)); 230 UpdateProperty(nameof(HasDetails)); 231 232 DoOnUiThread( 233 () => 234 { 235 if (HasDetails) 236 { 237 WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(Details)); 238 } 239 else 240 { 241 WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); 242 } 243 }); 244 } 245 246 // InvokeItemCommand is what this will be in Xaml due to source generator 247 // this comes in on Enter keypresses in the SearchBox 248 [RelayCommand] 249 private void InvokePrimaryCommand(ContentPageViewModel page) 250 { 251 if (PrimaryCommand is not null) 252 { 253 WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model)); 254 } 255 } 256 257 // this comes in on Ctrl+Enter keypresses in the SearchBox 258 [RelayCommand] 259 private void InvokeSecondaryCommand(ContentPageViewModel page) 260 { 261 if (SecondaryCommand is not null) 262 { 263 WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model)); 264 } 265 } 266 267 protected override void UnsafeCleanup() 268 { 269 base.UnsafeCleanup(); 270 271 Details?.SafeCleanup(); 272 273 Commands 274 .OfType<CommandContextItemViewModel>() 275 .ToList() 276 .ForEach(item => item.SafeCleanup()); 277 278 Commands.Clear(); 279 280 foreach (var item in Content) 281 { 282 item.SafeCleanup(); 283 } 284 285 Content.Clear(); 286 287 var model = _model.Unsafe; 288 if (model is not null) 289 { 290 model.ItemsChanged -= Model_ItemsChanged; 291 } 292 } 293 }