ExtensionObjectViewModel.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.Buffers; 6 using System.Collections.Concurrent; 7 using System.ComponentModel; 8 using System.Runtime.CompilerServices; 9 using CommunityToolkit.Mvvm.ComponentModel; 10 using Microsoft.CmdPal.Core.Common; 11 using Microsoft.CmdPal.Core.Common.Helpers; 12 13 namespace Microsoft.CmdPal.Core.ViewModels; 14 15 public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatchUpdateTarget, IBackgroundPropertyChangedNotification 16 { 17 private const int InitialPropertyBatchingBufferSize = 16; 18 19 // Raised on the background thread before UI notifications. It's raised on the background thread to prevent 20 // blocking the COM proxy. 21 public event PropertyChangedEventHandler? PropertyChangedBackground; 22 23 private readonly ConcurrentQueue<string> _pendingProps = []; 24 25 private readonly TaskScheduler _uiScheduler; 26 27 private InterlockedBoolean _batchQueued; 28 29 public WeakReference<IPageContext> PageContext { get; private set; } = null!; 30 31 TaskScheduler IBatchUpdateTarget.UIScheduler => _uiScheduler; 32 33 void IBatchUpdateTarget.ApplyPendingUpdates() => ApplyPendingUpdates(); 34 35 bool IBatchUpdateTarget.TryMarkBatchQueued() => _batchQueued.Set(); 36 37 void IBatchUpdateTarget.ClearBatchQueued() => _batchQueued.Clear(); 38 39 private protected ExtensionObjectViewModel(TaskScheduler scheduler) 40 { 41 if (this is not IPageContext) 42 { 43 throw new InvalidOperationException($"Constructor overload without IPageContext can only be used when the derived class implements IPageContext. Type: {GetType().FullName}"); 44 } 45 46 _uiScheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); 47 48 // Defer PageContext assignment - derived constructor MUST call InitializePageContext() 49 // or we set it lazily on first access 50 } 51 52 private protected ExtensionObjectViewModel(IPageContext context) 53 { 54 ArgumentNullException.ThrowIfNull(context); 55 56 PageContext = new WeakReference<IPageContext>(context); 57 _uiScheduler = context.Scheduler; 58 59 LogIfDefaultScheduler(); 60 } 61 62 private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef) 63 { 64 ArgumentNullException.ThrowIfNull(contextRef); 65 66 if (!contextRef.TryGetTarget(out var context)) 67 { 68 throw new ArgumentException("IPageContext must be alive when creating view models.", nameof(contextRef)); 69 } 70 71 PageContext = contextRef; 72 _uiScheduler = context.Scheduler; 73 74 LogIfDefaultScheduler(); 75 } 76 77 protected void InitializeSelfAsPageContext() 78 { 79 if (this is not IPageContext self) 80 { 81 throw new InvalidOperationException("This method can only be called when the class implements IPageContext."); 82 } 83 84 PageContext = new WeakReference<IPageContext>(self); 85 } 86 87 private void LogIfDefaultScheduler() 88 { 89 if (_uiScheduler == TaskScheduler.Default) 90 { 91 CoreLogger.LogDebug($"ExtensionObjectViewModel created with TaskScheduler.Default. Type: {GetType().FullName}"); 92 } 93 } 94 95 public virtual Task InitializePropertiesAsync() 96 => Task.Run(SafeInitializePropertiesSynchronous); 97 98 public void SafeInitializePropertiesSynchronous() 99 { 100 try 101 { 102 InitializeProperties(); 103 } 104 catch (Exception ex) 105 { 106 ShowException(ex); 107 } 108 } 109 110 public abstract void InitializeProperties(); 111 112 protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName); 113 114 protected void UpdateProperty(string propertyName1, string propertyName2) 115 { 116 MarkPropertyDirty(propertyName1); 117 MarkPropertyDirty(propertyName2); 118 } 119 120 protected void UpdateProperty(params string[] propertyNames) 121 { 122 foreach (var p in propertyNames) 123 { 124 MarkPropertyDirty(p); 125 } 126 } 127 128 internal void MarkPropertyDirty(string? propertyName) 129 { 130 if (string.IsNullOrEmpty(propertyName)) 131 { 132 return; 133 } 134 135 // We should re-consider if this worth deduping 136 _pendingProps.Enqueue(propertyName); 137 BatchUpdateManager.Queue(this); 138 } 139 140 public void ApplyPendingUpdates() 141 { 142 ((IBatchUpdateTarget)this).ClearBatchQueued(); 143 144 var buffer = ArrayPool<string>.Shared.Rent(InitialPropertyBatchingBufferSize); 145 var count = 0; 146 var transferred = false; 147 148 try 149 { 150 while (_pendingProps.TryDequeue(out var name)) 151 { 152 if (count == buffer.Length) 153 { 154 var bigger = ArrayPool<string>.Shared.Rent(buffer.Length * 2); 155 Array.Copy(buffer, bigger, buffer.Length); 156 ArrayPool<string>.Shared.Return(buffer, clearArray: true); 157 buffer = bigger; 158 } 159 160 buffer[count++] = name; 161 } 162 163 if (count == 0) 164 { 165 return; 166 } 167 168 // 1) Background subscribers (must be raised before UI notifications). 169 var propertyChangedEventHandler = PropertyChangedBackground; 170 if (propertyChangedEventHandler is not null) 171 { 172 RaiseBackground(propertyChangedEventHandler, this, buffer, count); 173 } 174 175 // 2) UI-facing PropertyChanged: ALWAYS marshal to UI scheduler. 176 // Hand-off pooled buffer to UI task (UI task returns it). 177 // 178 // It would be lovely to do nothing if no one is actually listening on PropertyChanged, 179 // but ObservableObject doesn't expose that information. 180 _ = Task.Factory.StartNew( 181 static state => 182 { 183 var p = (UiBatch)state!; 184 try 185 { 186 p.Owner.RaiseUi(p.Names, p.Count); 187 } 188 catch (Exception ex) 189 { 190 CoreLogger.LogError("Failed to raise property change notifications on UI thread.", ex); 191 } 192 finally 193 { 194 ArrayPool<string>.Shared.Return(p.Names, clearArray: true); 195 } 196 }, 197 new UiBatch(this, buffer, count), 198 CancellationToken.None, 199 TaskCreationOptions.DenyChildAttach, 200 _uiScheduler); 201 202 transferred = true; 203 } 204 catch (Exception ex) 205 { 206 CoreLogger.LogError("Failed to apply pending property updates.", ex); 207 } 208 finally 209 { 210 if (!transferred) 211 { 212 ArrayPool<string>.Shared.Return(buffer, clearArray: true); 213 } 214 } 215 } 216 217 private void RaiseUi(string[] names, int count) 218 { 219 for (var i = 0; i < count; i++) 220 { 221 OnPropertyChanged(Args(names[i])); 222 } 223 } 224 225 private static void RaiseBackground(PropertyChangedEventHandler handlers, object sender, string[] names, int count) 226 { 227 try 228 { 229 for (var i = 0; i < count; i++) 230 { 231 handlers(sender, Args(names[i])); 232 } 233 } 234 catch (Exception ex) 235 { 236 CoreLogger.LogError("Failed to raise PropertyChangedBackground notifications.", ex); 237 } 238 } 239 240 private sealed record UiBatch(ExtensionObjectViewModel Owner, string[] Names, int Count); 241 242 protected void ShowException(Exception ex, string? extensionHint = null) 243 { 244 if (PageContext.TryGetTarget(out var pageContext)) 245 { 246 pageContext.ShowException(ex, extensionHint); 247 } 248 else 249 { 250 CoreLogger.LogError("Failed to show exception because PageContext is no longer available.", ex); 251 } 252 } 253 254 [MethodImpl(MethodImplOptions.AggressiveInlining)] 255 private static PropertyChangedEventArgs Args(string name) => new(name); 256 257 protected void DoOnUiThread(Action action) 258 { 259 if (PageContext.TryGetTarget(out var pageContext)) 260 { 261 Task.Factory.StartNew( 262 action, 263 CancellationToken.None, 264 TaskCreationOptions.None, 265 pageContext.Scheduler); 266 } 267 } 268 269 protected virtual void UnsafeCleanup() 270 { 271 // base doesn't do anything, but sub-classes should override this. 272 } 273 274 public virtual void SafeCleanup() 275 { 276 try 277 { 278 UnsafeCleanup(); 279 } 280 catch (Exception ex) 281 { 282 CoreLogger.LogDebug(ex.ToString()); 283 } 284 } 285 }