From 6a51a582244e484640984fa0988d4c2368f724fb Mon Sep 17 00:00:00 2001 From: PointStar Date: Tue, 9 Jun 2026 15:58:53 +0300 Subject: [PATCH] fix(save-attachments): marshal folder-tree mutations to the Dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.2.15's tree picker built the whole tree inside an async method and mutated the TreeView-bound ObservableCollections after an await. In this VSTO host the WPF SynchronizationContext is not installed, so ConfigureAwait(true) resumed on a thread-pool thread and FolderTree.Add threw "changes to its SourceCollection from a thread different from the Dispatcher thread" — the exception was swallowed and the tree showed empty (no folders selectable at all). Capture the UI Dispatcher in the view-model and marshal every bound collection mutation (FolderTree + each node's Children) through it, so the tree populates regardless of which thread the network await resumes on. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ViewModels/FolderNodeViewModel.cs | 24 +++++++++++-- .../ViewModels/SaveAttachmentsViewModel.cs | 36 +++++++++++++++---- src/OutlookAddin/Properties/AssemblyInfo.cs | 4 +-- 3 files changed, 52 insertions(+), 12 deletions(-) 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")]