Compare commits

...

1 Commits

Author SHA1 Message Date
PointStar 6a51a58224 fix(save-attachments): marshal folder-tree mutations to the Dispatcher
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) <noreply@anthropic.com>
2026-06-09 15:58:53 +03:00
3 changed files with 52 additions and 12 deletions
@@ -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;
@@ -132,9 +136,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
_logger?.Warning(ex, "Listing subfolders under '{Path}' failed", FullPath);
}
finally
{
// 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);
}
}
}
@@ -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
}
}
/// <summary>Runs <paramref name="action"/> on the UI/Dispatcher thread.</summary>
private void OnUi(Action action)
{
if (_dispatcher.CheckAccess()) action();
else _dispatcher.Invoke(action);
}
private void ResetFolderTree()
{
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);
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);
}
/// <summary>
+2 -2
View File
@@ -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")]