WeakReferenceMessenger.Default.Send on ComboBox selection change
TenantSwitchedMessage
Build the WPF shell — MainWindow XAML + all ViewModels. Wire LogPanelSink to the RichTextBox. Implement FeatureViewModelBase with the canonical async pattern. Register global exception handlers.
Purpose: This is the first time the application visually exists. All subsequent feature plans add TabItems to the already-wired TabControl.
Output: Runnable WPF application showing the shell with placeholder tabs, log panel, and status bar.
Task 1: FeatureViewModelBase + unit tests
SharepointToolbox/ViewModels/FeatureViewModelBase.cs,
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
- Test: IsRunning is true while operation executes, false after completion
- Test: ProgressValue and StatusMessage update via IProgress on UI thread
- Test: Calling CancelCommand during operation causes StatusMessage to show cancellation message
- Test: OperationCanceledException is caught gracefully — IsRunning becomes false, no exception propagates
- Test: Exception during operation sets StatusMessage to error text — IsRunning becomes false
- Test: RunCommand cannot be invoked while IsRunning (CanExecute returns false)
Create `ViewModels/` directory.
**FeatureViewModelBase.cs** — implement exactly as per research Pattern 2:
```csharp
namespace SharepointToolbox.ViewModels;
public abstract class FeatureViewModelBase : ObservableRecipient
{
private CancellationTokenSource? _cts;
private readonly ILogger<FeatureViewModelBase> _logger;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
private bool _isRunning;
[ObservableProperty]
private string _statusMessage = string.Empty;
[ObservableProperty]
private int _progressValue;
public IAsyncRelayCommand RunCommand { get; }
public RelayCommand CancelCommand { get; }
protected FeatureViewModelBase(ILogger<FeatureViewModelBase> logger)
{
_logger = logger;
RunCommand = new AsyncRelayCommand(ExecuteAsync, () => !IsRunning);
CancelCommand = new RelayCommand(() => _cts?.Cancel(), () => IsRunning);
IsActive = true; // Activates ObservableRecipient for WeakReferenceMessenger
}
private async Task ExecuteAsync()
{
_cts = new CancellationTokenSource();
IsRunning = true;
StatusMessage = string.Empty;
ProgressValue = 0;
try
{
var progress = new Progress<OperationProgress>(p =>
{
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
StatusMessage = p.Message;
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
});
await RunOperationAsync(_cts.Token, progress);
}
catch (OperationCanceledException)
{
StatusMessage = TranslationSource.Instance["status.cancelled"];
_logger.LogInformation("Operation cancelled by user.");
}
catch (Exception ex)
{
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
_logger.LogError(ex, "Operation failed.");
}
finally
{
IsRunning = false;
_cts?.Dispose();
_cts = null;
}
}
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
protected override void OnActivated()
{
Messenger.Register<TenantSwitchedMessage>(this, (r, m) => r.OnTenantSwitched(m.Value));
}
protected virtual void OnTenantSwitched(TenantProfile profile)
{
// Derived classes override to reset their state
}
}
```
Also create `Core/Messages/ProgressUpdatedMessage.cs` (needed for StatusBar update):
```csharp
public sealed class ProgressUpdatedMessage : ValueChangedMessage<OperationProgress>
{
public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { }
}
```
**FeatureViewModelBaseTests.cs** — Replace stub. Use a concrete test subclass:
```csharp
private class TestViewModel : FeatureViewModelBase
{
public TestViewModel(ILogger<FeatureViewModelBase> logger) : base(logger) { }
public Func<CancellationToken, IProgress<OperationProgress>, Task>? OperationFunc { get; set; }
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> p)
=> OperationFunc?.Invoke(ct, p) ?? Task.CompletedTask;
}
```
All tests in `[Trait("Category", "Unit")]`.
cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~FeatureViewModelBaseTests" 2>&1 | tail -10
All FeatureViewModelBaseTests pass. IsRunning lifecycle correct. Cancellation handled gracefully. Exception caught with error message set.
Task 2: MainWindowViewModel, shell ViewModels, and MainWindow XAML
SharepointToolbox/ViewModels/MainWindowViewModel.cs,
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
SharepointToolbox/Views/MainWindow.xaml,
SharepointToolbox/Views/MainWindow.xaml.cs,
SharepointToolbox/App.xaml.cs
Create `ViewModels/Tabs/` and `Views/` directories.
cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5
Build succeeds with 0 errors. MainWindow.xaml contains RichTextBox x:Name="LogPanel". All 8 tab headers use TranslationSource bindings. Global exception handlers registered in App.xaml.cs.
- `dotnet build SharepointToolbox.sln` passes with 0 errors
- `dotnet test --filter "Category=Unit"` all pass
- MainWindow.xaml contains `x:Name="LogPanel"` RichTextBox
- App.xaml.cs registers `DispatcherUnhandledException` and `TaskScheduler.UnobservedTaskException`
- FeatureViewModelBase contains no `async void` methods (anti-pattern violation)
- ObservableCollection is never modified from Task.Run (pattern 7 compliance)
<success_criteria>
Application compiles and launches to a visible WPF shell. FeatureViewModelBase tests green. All ViewModels registered in DI. Log panel wired to Serilog.
</success_criteria>
After completion, create `.planning/phases/01-foundation/01-06-SUMMARY.md`