Build the WPF shell — MainWindow XAML + all ViewModels. Wire LogPanelSink to the RichTextBox. Implement FeatureViewModelBase with the canonical async pattern. Create FeatureTabBase UserControl (per-tab progress/cancel strip). 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. FeatureTabBase gives Phase 2+ a XAML template to extend rather than stub TextBlocks.
Output: Runnable WPF application showing the shell with placeholder tabs (using FeatureTabBase), log panel, and status bar with live operation text.
Task 1: FeatureViewModelBase + unit tests
SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs,
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: FeatureTabBase UserControl, MainWindowViewModel, shell ViewModels, and MainWindow XAML
SharepointToolbox/Views/Controls/FeatureTabBase.xaml,
SharepointToolbox/Views/Controls/FeatureTabBase.xaml.cs,
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 `Views/Controls/`, `ViewModels/Tabs/`, and `Views/` directories.
**FeatureTabBase.xaml** — UserControl that every stub feature tab uses as its Content.
This gives Phase 2+ a concrete XAML template to replace rather than a bare TextBlock.
The progress/cancel strip is Visibility-bound to IsRunning per the locked CONTEXT.md decision.
```xml
<UserControl x:Class="SharepointToolbox.Views.Controls.FeatureTabBase"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" /> <!-- Feature content area (Phase 2+ replaces this) -->
<RowDefinition Height="Auto" /> <!-- Progress/cancel strip -->
</Grid.RowDefinitions>
<!-- Placeholder content — Phase 2+ replaces Row 0 -->
<TextBlock Grid.Row="0"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.comingsoon]}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
<!-- Per-tab progress/cancel strip (locked CONTEXT.md: shown only when IsRunning) -->
<Grid Grid.Row="1" Margin="8,4"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ProgressBar Grid.Column="0" Height="16" Minimum="0" Maximum="100"
Value="{Binding ProgressValue}" />
<TextBlock Grid.Column="1" Margin="8,0" VerticalAlignment="Center"
Text="{Binding StatusMessage}" />
<Button Grid.Column="2"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Width="70" />
</Grid>
</Grid>
</UserControl>
```
**FeatureTabBase.xaml.cs**: Standard code-behind with no extra logic (DataContext is set by the parent TabItem's DataContext chain).
Add `BoolToVisibilityConverter` to App.xaml resources if not already present:
```xml
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
```
**MainWindowViewModel.cs**:
```csharp
[ObservableProperty] private TenantProfile? _selectedProfile;
[ObservableProperty] private string _connectionStatus = "Not connected";
[ObservableProperty] private string _progressStatus = string.Empty;
[ObservableProperty] private int _progressPercentage;
public ObservableCollection<TenantProfile> TenantProfiles { get; } = new();
// ConnectCommand: calls SessionManager.GetOrCreateContextAsync(SelectedProfile)
// ClearSessionCommand: calls SessionManager.ClearSessionAsync(SelectedProfile.TenantUrl)
// ManageProfilesCommand: opens ProfileManagementDialog as modal
// OnSelectedProfileChanged (partial): sends TenantSwitchedMessage via WeakReferenceMessenger
// LoadProfilesAsync: called on startup, loads from ProfileService
```
Override `OnActivated()` to register for `ProgressUpdatedMessage` from any active feature ViewModel:
```csharp
protected override void OnActivated()
{
base.OnActivated();
Messenger.Register<ProgressUpdatedMessage>(this, (r, m) =>
{
r.ProgressStatus = m.Value.Message;
r.ProgressPercentage = m.Value.Total > 0
? (int)(100.0 * m.Value.Current / m.Value.Total)
: 0;
});
}
```
This wires the StatusBar operation text and progress % to live updates from any running feature operation.
**ProfileManagementViewModel.cs**: Wraps ProfileService for dialog binding.
- `ObservableCollection<TenantProfile> Profiles`
- `AddCommand`, `RenameCommand`, `DeleteCommand`
- Validates inputs (non-empty Name, valid URL format, non-empty ClientId)
**SettingsViewModel.cs** (inherits FeatureViewModelBase):
- `string SelectedLanguage` bound to language ComboBox
- `string DataFolder` bound to folder TextBox
- `BrowseFolderCommand` opens FolderBrowserDialog
- On language change: updates `TranslationSource.Instance.CurrentCulture` + calls `SettingsService.SetLanguageAsync`
- `RunOperationAsync`: not applicable — stub throws `NotSupportedException` (Settings tab has no long-running operation)
**MainWindow.xaml** — Full shell layout as locked in CONTEXT.md.
StatusBar middle item MUST bind to `ProgressStatus` (live operation text from ProgressUpdatedMessage),
NOT `ConnectionStatus`. Per locked CONTEXT.md: "operation status text" means the live progress text.
The 7 stub feature tabs MUST use `<controls:FeatureTabBase />` as their Content,
NOT bare TextBlocks. This gives Phase 2 a XAML template to extend.
```xml
<Window Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
MinWidth="900" MinHeight="600">
<DockPanel>
<!-- Toolbar -->
<ToolBar DockPanel.Dock="Top">
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
Command="{Binding ConnectCommand}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" />
<Separator />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.clear]}"
Command="{Binding ClearSessionCommand}" />
</ToolBar>
<!-- StatusBar: three fields per locked layout decision.
Middle field binds to ProgressStatus (live operation text), NOT ConnectionStatus. -->
<StatusBar DockPanel.Dock="Bottom" Height="24">
<StatusBarItem Content="{Binding SelectedProfile.Name}" />
<Separator />
<StatusBarItem Content="{Binding ProgressStatus}" />
<Separator />
<StatusBarItem Content="{Binding ProgressPercentage, StringFormat={}{0}%}" />
</StatusBar>
<!-- Log Panel -->
<RichTextBox x:Name="LogPanel" DockPanel.Dock="Bottom" Height="150"
IsReadOnly="True" VerticalScrollBarVisibility="Auto"
Background="Black" Foreground="LimeGreen"
FontFamily="Consolas" FontSize="11" />
<!-- TabControl: 7 stub tabs use FeatureTabBase; Settings tab wired in plan 01-07 -->
<TabControl>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.filesearch]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.templates]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.bulk]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.folderstructure]}">
<controls:FeatureTabBase />
</TabItem>
<!-- Settings tab: placeholder TextBlock replaced by SettingsView in plan 01-07 -->
<TabItem x:Name="SettingsTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.settings]}">
<TextBlock Text="Settings (plan 01-07)" HorizontalAlignment="Center" VerticalAlignment="Center" />
</TabItem>
</TabControl>
</DockPanel>
</Window>
```
Add namespace in Window opening tag:
`xmlns:controls="clr-namespace:SharepointToolbox.Views.Controls"`
**MainWindow.xaml.cs**: Constructor receives `MainWindowViewModel` via DI constructor injection. Sets `DataContext = viewModel`. Calls `viewModel.LoadProfilesAsync()` in `Loaded` event.
**App.xaml.cs** — Update RegisterServices:
```csharp
services.AddSingleton<MsalClientFactory>();
services.AddSingleton<SessionManager>();
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<MainWindowViewModel>();
services.AddTransient<ProfileManagementViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddSingleton<MainWindow>();
```
Wire LogPanelSink AFTER MainWindow is resolved (it needs the RichTextBox reference):
```csharp
host.Start();
App app = new();
app.InitializeComponent();
var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox
Log.Logger = new LoggerConfiguration()
.WriteTo.File(/* rolling file path */)
.WriteTo.Sink(new LogPanelSink(mainWindow.LogPanel))
.CreateLogger();
app.MainWindow = mainWindow;
app.MainWindow.Visibility = Visibility.Visible;
```
**Global exception handlers** in App.xaml.cs (after app created):
```csharp
app.DispatcherUnhandledException += (s, e) =>
{
Log.Fatal(e.Exception, "Unhandled UI exception");
MessageBox.Show(
$"A fatal error occurred:\n{e.Exception.Message}\n\nCheck log for details.",
"Fatal Error", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
Log.Fatal(e.Exception, "Unobserved task exception");
e.SetObserved();
};
```
Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` — fix any XAML or CS compilation errors.
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". StatusBar middle StatusBarItem binds to ProgressStatus (not ConnectionStatus). All 7 stub feature TabItems contain <controls:FeatureTabBase /> (not bare TextBlocks). Settings TabItem has x:Name="SettingsTabItem". 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
- MainWindow.xaml StatusBar middle StatusBarItem binds to `ProgressStatus` (live operation text)
- MainWindow.xaml 7 stub TabItems contain `controls:FeatureTabBase` (not TextBlocks)
- FeatureTabBase.xaml contains ProgressBar + TextBlock + Button with Visibility bound to IsRunning
- 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)
- MainWindowViewModel.OnActivated() subscribes to ProgressUpdatedMessage and updates ProgressStatus + ProgressPercentage
<success_criteria>
Application compiles and launches to a visible WPF shell. FeatureViewModelBase tests green. All ViewModels registered in DI. Log panel wired to Serilog. StatusBar middle field shows live operation status text (ProgressStatus). All 7 stub feature tabs include the progress/cancel strip template via FeatureTabBase.
</success_criteria>
After completion, create `.planning/phases/01-foundation/01-06-SUMMARY.md`