Compare commits

...

2 Commits

Author SHA1 Message Date
PointStar d7a53da59f fix(sidebar): resolve MatchingService live + settings "סגור" label
Sidebar lookups threw ObjectDisposedException on the EspoCrmClient's
HttpClient after any settings save. TryInitializeCrm rebuilds the CRM
stack (new HttpClient + MatchingService) and disposes the old HttpClient,
but the long-lived sidebar ReadingPaneViewModel had captured the old
MatchingService and kept calling into the disposed client.

ReadingPaneViewModel now resolves the MatchingService live on each lookup
via a Func<IMatchingService?> provider (() => host.MatchingService), so
it always uses the current stack and degrades gracefully (shows "not
configured") when none exists. SidebarController no longer captures a
snapshot.

Also: rename the Settings dialog's bottom button from "ביטול" to "סגור".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:10:47 +03:00
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
7 changed files with 80 additions and 23 deletions
@@ -152,7 +152,7 @@
MinWidth="100" />
<Button Command="{Binding CancelCommand}"
IsCancel="True"
Content="ביטול"
Content="סגור"
Padding="20,5"
MinWidth="100" />
</StackPanel>
@@ -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);
}
}
}
@@ -27,7 +27,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public sealed partial class ReadingPaneViewModel : ObservableObject
{
private readonly IMatchingService _matchingService;
// Resolved live on each lookup rather than captured once: the host
// rebuilds the CRM stack (new HttpClient + MatchingService) whenever
// settings are saved and disposes the old HttpClient. A captured
// snapshot would then call into a disposed HttpClient and throw
// ObjectDisposedException on every sidebar lookup.
private readonly Func<IMatchingService?> _matchingServiceProvider;
private readonly ILogger _logger;
private CancellationTokenSource? _activeLookup;
// Stashes the ambiguous MatchResult while the user is drilling into
@@ -94,9 +99,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary>
public event EventHandler<string>? OpenInBrowserRequested;
public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger)
public ReadingPaneViewModel(Func<IMatchingService?> matchingServiceProvider, ILogger logger)
{
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_matchingServiceProvider = matchingServiceProvider ?? throw new ArgumentNullException(nameof(matchingServiceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -162,12 +167,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
_ambiguousReturnTarget = null;
State = ReadingPaneState.Loading;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
var cts = new CancellationTokenSource();
_activeLookup = cts;
try
{
var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
var result = await svc.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
if (cts.IsCancellationRequested) return;
if (result == null)
@@ -296,6 +309,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
CancelInFlight();
var cts = new CancellationTokenSource();
_activeLookup = cts;
@@ -310,7 +331,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
try
{
var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
var result = await svc.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
if (cts.IsCancellationRequested) return;
if (result == null || result.Contact == null)
@@ -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.17.0")]
[assembly: AssemblyFileVersion("1.2.17.0")]
-1
View File
@@ -367,7 +367,6 @@ namespace OutlookAddin.Services
_sidebarController = new SidebarController(
taskPanes,
_outlookApp,
MatchingService!,
this,
System.Windows.Threading.Dispatcher.CurrentDispatcher,
Logger);
@@ -27,7 +27,6 @@ namespace OutlookAddin.Services
private readonly CustomTaskPaneCollection _taskPanes;
private readonly Outlook.Application _app;
private readonly IMatchingService _matchingService;
private readonly AddInHost _host;
private readonly Dispatcher _dispatcher;
private readonly ILogger _logger;
@@ -38,14 +37,12 @@ namespace OutlookAddin.Services
public SidebarController(
CustomTaskPaneCollection taskPanes,
Outlook.Application app,
IMatchingService matchingService,
AddInHost host,
Dispatcher dispatcher,
ILogger logger)
{
_taskPanes = taskPanes ?? throw new ArgumentNullException(nameof(taskPanes));
_app = app ?? throw new ArgumentNullException(nameof(app));
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_host = host ?? throw new ArgumentNullException(nameof(host));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -83,7 +80,7 @@ namespace OutlookAddin.Services
if (_bindings.ContainsKey(key)) return;
var view = new ReadingPaneView();
var vm = new ReadingPaneViewModel(_matchingService, _logger)
var vm = new ReadingPaneViewModel(() => _host.MatchingService, _logger)
{
EspoCrmBaseUrl = _host.Settings.EspoCrmUrl
};