/ src / modules / cmdpal / Core / Microsoft.CmdPal.Core.ViewModels / ExtensionObjectViewModel.cs
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  }