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>
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using MarcusLaw.OutlookAddin.Core.Services;
|
using MarcusLaw.OutlookAddin.Core.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -18,6 +19,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
{
|
{
|
||||||
private readonly IEspoCrmClient? _client;
|
private readonly IEspoCrmClient? _client;
|
||||||
private readonly ILogger? _logger;
|
private readonly ILogger? _logger;
|
||||||
|
private readonly Dispatcher? _dispatcher;
|
||||||
private bool _childrenLoaded;
|
private bool _childrenLoaded;
|
||||||
private bool _loadingStarted;
|
private bool _loadingStarted;
|
||||||
|
|
||||||
@@ -58,12 +60,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
public FolderNodeViewModel(
|
public FolderNodeViewModel(
|
||||||
IEspoCrmClient client,
|
IEspoCrmClient client,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
Dispatcher dispatcher,
|
||||||
string name,
|
string name,
|
||||||
string fullPath,
|
string fullPath,
|
||||||
string? relativePath)
|
string? relativePath)
|
||||||
{
|
{
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
Name = name;
|
Name = name;
|
||||||
FullPath = fullPath;
|
FullPath = fullPath;
|
||||||
RelativePath = relativePath;
|
RelativePath = relativePath;
|
||||||
@@ -133,8 +137,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Children.Clear();
|
// Marshal onto the Dispatcher: once this node's TreeViewItem
|
||||||
foreach (var child in loaded) Children.Add(child);
|
// 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;
|
_childrenLoaded = true;
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -144,7 +155,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
{
|
{
|
||||||
var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name;
|
var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name;
|
||||||
var relativePath = IsRoot ? name : RelativePath + "/" + 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.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using MarcusLaw.OutlookAddin.Core.Models;
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
@@ -31,6 +32,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
|
|
||||||
private readonly IEspoCrmClient _client;
|
private readonly IEspoCrmClient _client;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
private readonly Dispatcher _dispatcher;
|
||||||
private CancellationTokenSource? _searchCts;
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -109,6 +111,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
{
|
{
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_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();
|
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()
|
private void ResetFolderTree()
|
||||||
{
|
{
|
||||||
FolderTree.Clear();
|
OnUi(() =>
|
||||||
SelectedFolder = null;
|
{
|
||||||
|
FolderTree.Clear();
|
||||||
|
SelectedFolder = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSearchTextChanged(string value)
|
partial void OnSearchTextChanged(string value)
|
||||||
@@ -234,10 +253,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
// default), expanded, and seeded with the default subfolders so
|
// default), expanded, and seeded with the default subfolders so
|
||||||
// they appear even before the share listing returns. Deeper levels
|
// they appear even before the share listing returns. Deeper levels
|
||||||
// load lazily as the user expands them.
|
// 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);
|
||||||
FolderTree.Add(root);
|
OnUi(() =>
|
||||||
SelectedFolder = root;
|
{
|
||||||
root.IsSelected = true;
|
FolderTree.Add(root);
|
||||||
|
SelectedFolder = root;
|
||||||
|
root.IsSelected = true;
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -247,7 +269,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
{
|
{
|
||||||
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
|
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
|
||||||
}
|
}
|
||||||
root.IsExpanded = true;
|
OnUi(() => root.IsExpanded = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ using System.Security;
|
|||||||
// You can specify all the values or you can default the Build and Revision Numbers
|
// You can specify all the values or you can default the Build and Revision Numbers
|
||||||
// by using the '*' as shown below:
|
// by using the '*' as shown below:
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
[assembly: AssemblyVersion("1.2.15.0")]
|
[assembly: AssemblyVersion("1.2.16.0")]
|
||||||
[assembly: AssemblyFileVersion("1.2.15.0")]
|
[assembly: AssemblyFileVersion("1.2.16.0")]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user