Files
Sharepoint-Toolbox/.planning/phases/01-foundation/01-06-PLAN.md
Dev eeb9a3bcd1 fix(01-foundation): revise plans based on checker feedback
- 01-03: wave 2 → wave 3 (depends on 01-02 which is also wave 2; must be wave 3)
- 01-06: add ProgressUpdatedMessage.cs to files_modified; add third StatusBarItem (progress %) to XAML per locked CONTEXT.md decision; add ProgressUpdatedMessage subscription in MainWindowViewModel.OnActivated()
- 01-08: add comment to empty <files> element (auto task with no file output)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:44:54 +02:00

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 06 execute 4
01-03
01-04
01-05
SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs
SharepointToolbox/ViewModels/FeatureViewModelBase.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
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
true
FOUND-01
FOUND-05
FOUND-06
FOUND-07
truths artifacts key_links
MainWindow displays: top toolbar, center TabControl with 8 feature tabs, bottom RichTextBox log panel (150px), bottom StatusBar
Toolbar ComboBox bound to TenantProfiles ObservableCollection; selecting a different item triggers TenantSwitchedMessage
FeatureViewModelBase provides CancellationTokenSource lifecycle, IsRunning, IProgress<OperationProgress>, OperationCanceledException handling
Global exception handlers (DispatcherUnhandledException + TaskScheduler.UnobservedTaskException) funnel to log panel + MessageBox
LogPanelSink wired to MainWindow RichTextBox after Generic Host starts
FeatureViewModelBaseTests: progress reporting, cancellation, and error handling all green
path provides contains
SharepointToolbox/ViewModels/FeatureViewModelBase.cs Base class for all feature ViewModels with canonical async command pattern CancellationTokenSource
path provides contains
SharepointToolbox/ViewModels/MainWindowViewModel.cs Shell ViewModel with TenantProfiles and connection state ObservableCollection
path provides contains
SharepointToolbox/Views/MainWindow.xaml WPF shell with toolbar, TabControl, log panel, StatusBar RichTextBox
from to via pattern
SharepointToolbox/Views/MainWindow.xaml SharepointToolbox/ViewModels/MainWindowViewModel.cs DataContext binding in MainWindow.xaml.cs constructor DataContext
from to via pattern
SharepointToolbox/App.xaml.cs SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs LoggerConfiguration.WriteTo.Sink(new LogPanelSink(mainWindow.LogPanel)) LogPanelSink
from to via pattern
SharepointToolbox/ViewModels/MainWindowViewModel.cs SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs WeakReferenceMessenger.Default.Send on ComboBox selection change TenantSwitchedMessage
from to via pattern
SharepointToolbox/ViewModels/MainWindowViewModel.cs SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs Messenger.Register<ProgressUpdatedMessage> in OnActivated — updates StatusBar observable properties ProgressUpdatedMessage
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.

<execution_context> @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/01-foundation/01-CONTEXT.md @.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-03-SUMMARY.md @.planning/phases/01-foundation/01-04-SUMMARY.md @.planning/phases/01-foundation/01-05-SUMMARY.md ```csharp public class TenantProfile { string Name; string TenantUrl; string ClientId; } public record OperationProgress(int Current, int Total, string Message) ```
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
// ProfileService: GetProfilesAsync(), AddProfileAsync(), RenameProfileAsync(), DeleteProfileAsync()
// SessionManager: IsAuthenticated(url), GetOrCreateContextAsync(profile, ct), ClearSessionAsync(url)
// SettingsService: GetSettingsAsync(), SetLanguageAsync(code), SetDataFolderAsync(path)
// TranslationSource.Instance[key] — binding: Source={x:Static loc:TranslationSource.Instance}, Path=[key]

// Toolbar (L→R): ComboBox (220px) → Button "Connect" → Button "Manage Profiles..." → separator → Button "Clear Session" // TabControl: 8 tabs (Permissions, Storage, File Search, Duplicates, Templates, Bulk Operations, Folder Structure, Settings) // Log panel: RichTextBox, 150px tall, always visible, x:Name="LogPanel" // StatusBar: tenant name | operation status | progress %

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: 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.
**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 MUST have three fields per the locked decision (tenant name | operation status text | progress percentage):
```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 -->
    <StatusBar DockPanel.Dock="Bottom" Height="24">
      <StatusBarItem Content="{Binding SelectedProfile.Name}" />
      <Separator />
      <StatusBarItem Content="{Binding ConnectionStatus}" />
      <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 -->
    <TabControl>
      <TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}">
        <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.comingsoon]}"
                   HorizontalAlignment="Center" VerticalAlignment="Center" />
      </TabItem>
      <!-- Repeat for: Storage, File Search, Duplicates, Templates, Bulk Operations, Folder Structure -->
      <!-- Settings tab binds to SettingsView (plan 01-07) -->
      <TabItem 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>
```

**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 has three StatusBarItems (tenant name, connection status, progress %). 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 has three StatusBarItems: SelectedProfile.Name | ConnectionStatus | ProgressPercentage% - 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 shows all three fields including live progress percentage. </success_criteria>

After completion, create `.planning/phases/01-foundation/01-06-SUMMARY.md`