diff --git a/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs b/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs
index a7dfbf5..765b5f2 100644
--- a/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs
+++ b/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
+using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
@@ -18,6 +19,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
private readonly IEspoCrmClient? _client;
private readonly ILogger? _logger;
+ private readonly Dispatcher? _dispatcher;
private bool _childrenLoaded;
private bool _loadingStarted;
@@ -58,12 +60,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public FolderNodeViewModel(
IEspoCrmClient client,
ILogger logger,
+ Dispatcher dispatcher,
string name,
string fullPath,
string? relativePath)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
Name = name;
FullPath = fullPath;
RelativePath = relativePath;
@@ -133,8 +137,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
finally
{
- Children.Clear();
- foreach (var child in loaded) Children.Add(child);
+ // Marshal onto the Dispatcher: once this node's TreeViewItem
+ // exists, Children is bound and must only be mutated on the UI
+ // thread (the network await above can resume on a thread-pool
+ // thread in this VSTO host).
+ OnUi(() =>
+ {
+ Children.Clear();
+ foreach (var child in loaded) Children.Add(child);
+ });
_childrenLoaded = true;
IsLoading = false;
}
@@ -144,7 +155,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name;
var relativePath = IsRoot ? name : RelativePath + "/" + name;
- return new FolderNodeViewModel(_client!, _logger!, name, fullPath, relativePath);
+ return new FolderNodeViewModel(_client!, _logger!, _dispatcher!, name, fullPath, relativePath);
+ }
+
+ private void OnUi(Action action)
+ {
+ var d = _dispatcher;
+ if (d == null || d.CheckAccess()) action();
+ else d.Invoke(action);
}
}
}
diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
index 957c9ba..cba5323 100644
--- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
+++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
+using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -31,6 +32,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
+ private readonly Dispatcher _dispatcher;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
@@ -109,6 +111,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ // Captured on the UI thread (the VM is constructed on the thread
+ // that shows the dialog). Used to marshal ObservableCollection
+ // mutations back onto the Dispatcher — in this VSTO host the WPF
+ // SynchronizationContext is not installed, so ConfigureAwait(true)
+ // resumes on a thread-pool thread and a bound-collection Add throws
+ // "changes ... from a thread different from the Dispatcher thread".
+ _dispatcher = Dispatcher.CurrentDispatcher;
ResetFolderTree();
@@ -150,10 +159,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
+ /// Runs on the UI/Dispatcher thread.
+ private void OnUi(Action action)
+ {
+ if (_dispatcher.CheckAccess()) action();
+ else _dispatcher.Invoke(action);
+ }
+
private void ResetFolderTree()
{
- FolderTree.Clear();
- SelectedFolder = null;
+ OnUi(() =>
+ {
+ FolderTree.Clear();
+ SelectedFolder = null;
+ });
}
partial void OnSearchTextChanged(string value)
@@ -234,10 +253,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
// default), expanded, and seeded with the default subfolders so
// they appear even before the share listing returns. Deeper levels
// load lazily as the user expands them.
- var root = new FolderNodeViewModel(_client, _logger, RootSentinel, folderPath, relativePath: null);
- FolderTree.Add(root);
- SelectedFolder = root;
- root.IsSelected = true;
+ var root = new FolderNodeViewModel(_client, _logger, _dispatcher, RootSentinel, folderPath, relativePath: null);
+ OnUi(() =>
+ {
+ FolderTree.Add(root);
+ SelectedFolder = root;
+ root.IsSelected = true;
+ });
try
{
@@ -247,7 +269,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
- root.IsExpanded = true;
+ OnUi(() => root.IsExpanded = true);
}
///
diff --git a/src/OutlookAddin/Properties/AssemblyInfo.cs b/src/OutlookAddin/Properties/AssemblyInfo.cs
index fd1779c..6745bd3 100644
--- a/src/OutlookAddin/Properties/AssemblyInfo.cs
+++ b/src/OutlookAddin/Properties/AssemblyInfo.cs
@@ -33,6 +33,6 @@ using System.Security;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.2.15.0")]
-[assembly: AssemblyFileVersion("1.2.15.0")]
+[assembly: AssemblyVersion("1.2.16.0")]
+[assembly: AssemblyFileVersion("1.2.16.0")]