---
phase: 03
plan: 07
title: StorageViewModel + StorageView XAML + DI Wiring
status: pending
wave: 3
depends_on:
  - 03-03
  - 03-06
files_modified:
  - SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
  - SharepointToolbox/Views/Tabs/StorageView.xaml
  - SharepointToolbox/Views/Tabs/StorageView.xaml.cs
  - SharepointToolbox/App.xaml.cs
  - SharepointToolbox/MainWindow.xaml
  - SharepointToolbox/MainWindow.xaml.cs
autonomous: true
requirements:
  - STOR-01
  - STOR-02
  - STOR-03
  - STOR-04
  - STOR-05

must_haves:
  truths:
    - "StorageView appears in the Storage tab (replaces FeatureTabBase stub) when the app runs"
    - "User can enter a site URL, set folder depth (0 = library root, or N levels), check per-library breakdown, and click Generate Metrics"
    - "DataGrid displays StorageNode rows with library name indented by IndentLevel, file count, total size, version size, last modified"
    - "Export buttons are enabled after a successful scan and disabled when Results is empty"
    - "Never modify ObservableCollection from a background thread — accumulate in List<T> on background, then Dispatcher.InvokeAsync"
    - "StorageViewModel never stores ClientContext — it calls ISessionManager.GetOrCreateContextAsync at operation start"
  artifacts:
    - path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs"
      provides: "Storage tab ViewModel (IStorageService orchestration)"
      exports: ["StorageViewModel"]
    - path: "SharepointToolbox/Views/Tabs/StorageView.xaml"
      provides: "Storage tab XAML (DataGrid + controls)"
    - path: "SharepointToolbox/Views/Tabs/StorageView.xaml.cs"
      provides: "StorageView code-behind"
  key_links:
    - from: "StorageViewModel.cs"
      to: "IStorageService.CollectStorageAsync"
      via: "RunOperationAsync override"
      pattern: "CollectStorageAsync"
    - from: "StorageViewModel.cs"
      to: "ISessionManager.GetOrCreateContextAsync"
      via: "context acquisition"
      pattern: "GetOrCreateContextAsync"
    - from: "StorageView.xaml"
      to: "StorageViewModel.Results"
      via: "DataGrid ItemsSource binding"
      pattern: "Results"
---

# Plan 03-07: StorageViewModel + StorageView XAML + DI Wiring

## Goal

Create the `StorageViewModel` (orchestrates `IStorageService`, export commands) and `StorageView` XAML (DataGrid with IndentLevel-based name indentation). Wire the Storage tab in `MainWindow` to replace the `FeatureTabBase` stub, register all dependencies in `App.xaml.cs`.

## Context

Plans 03-02 (StorageService), 03-03 (export services), and 03-06 (localization) must complete before this plan. The ViewModel follows the exact pattern from `PermissionsViewModel`: `FeatureViewModelBase` base class, `AsyncRelayCommand` for exports, `ObservableCollection` updated via `Dispatcher.InvokeAsync` from background thread.

`MainWindow.xaml` currently has the Storage tab as:
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
  <controls:FeatureTabBase />
</TabItem>
```
This plan adds `x:Name="StorageTabItem"` to that TabItem and wires `StorageTabItem.Content` in `MainWindow.xaml.cs`.

The `IndentConverter` value converter maps `IndentLevel` (int) → `Thickness(IndentLevel * 16, 0, 0, 0)`. It must be defined in the View or a shared Resources file.

## Tasks

### Task 1: Create StorageViewModel

**File:** `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs`

**Action:** Create

**Why:** Storage tab business logic — orchestrates StorageService scan, holds results, triggers exports.

```csharp
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class StorageViewModel : FeatureViewModelBase
{
    private readonly IStorageService _storageService;
    private readonly ISessionManager _sessionManager;
    private readonly StorageCsvExportService _csvExportService;
    private readonly StorageHtmlExportService _htmlExportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;

    [ObservableProperty]
    private string _siteUrl = string.Empty;

    [ObservableProperty]
    private bool _perLibrary = true;

    [ObservableProperty]
    private bool _includeSubsites;

    [ObservableProperty]
    private int _folderDepth;

    public bool IsMaxDepth
    {
        get => FolderDepth >= 999;
        set
        {
            if (value) FolderDepth = 999;
            else if (FolderDepth >= 999) FolderDepth = 0;
            OnPropertyChanged();
        }
    }

    private ObservableCollection<StorageNode> _results = new();
    public ObservableCollection<StorageNode> Results
    {
        get => _results;
        private set
        {
            _results = value;
            OnPropertyChanged();
            ExportCsvCommand.NotifyCanExecuteChanged();
            ExportHtmlCommand.NotifyCanExecuteChanged();
        }
    }

    public IAsyncRelayCommand ExportCsvCommand { get; }
    public IAsyncRelayCommand ExportHtmlCommand { get; }

    public TenantProfile? CurrentProfile => _currentProfile;

    public StorageViewModel(
        IStorageService storageService,
        ISessionManager sessionManager,
        StorageCsvExportService csvExportService,
        StorageHtmlExportService htmlExportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _storageService    = storageService;
        _sessionManager    = sessionManager;
        _csvExportService  = csvExportService;
        _htmlExportService = htmlExportService;
        _logger            = logger;

        ExportCsvCommand  = new AsyncRelayCommand(ExportCsvAsync,  CanExport);
        ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
    }

    /// <summary>Test constructor — omits export services.</summary>
    internal StorageViewModel(
        IStorageService storageService,
        ISessionManager sessionManager,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _storageService    = storageService;
        _sessionManager    = sessionManager;
        _csvExportService  = null!;
        _htmlExportService = null!;
        _logger            = logger;

        ExportCsvCommand  = new AsyncRelayCommand(ExportCsvAsync,  CanExport);
        ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null)
        {
            StatusMessage = "No tenant selected. Please connect to a tenant first.";
            return;
        }
        if (string.IsNullOrWhiteSpace(SiteUrl))
        {
            StatusMessage = "Please enter a site URL.";
            return;
        }

        var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
        // Override URL to the site URL the user entered (may differ from tenant root)
        ctx.Url = SiteUrl.TrimEnd('/');

        var options = new StorageScanOptions(
            PerLibrary: PerLibrary,
            IncludeSubsites: IncludeSubsites,
            FolderDepth: FolderDepth);

        var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);

        // Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
        var flat = new List<StorageNode>();
        foreach (var node in nodes)
            FlattenNode(node, 0, flat);

        if (Application.Current?.Dispatcher is { } dispatcher)
        {
            await dispatcher.InvokeAsync(() =>
            {
                Results = new ObservableCollection<StorageNode>(flat);
            });
        }
        else
        {
            Results = new ObservableCollection<StorageNode>(flat);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        Results = new ObservableCollection<StorageNode>();
        SiteUrl = string.Empty;
        OnPropertyChanged(nameof(CurrentProfile));
        ExportCsvCommand.NotifyCanExecuteChanged();
        ExportHtmlCommand.NotifyCanExecuteChanged();
    }

    internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;

    internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
        => RunOperationAsync(ct, progress);

    private bool CanExport() => Results.Count > 0;

    private async Task ExportCsvAsync()
    {
        if (Results.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export storage metrics to CSV",
            Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
            DefaultExt = "csv",
            FileName = "storage_metrics"
        };
        if (dialog.ShowDialog() != true) return;
        try
        {
            await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
            OpenFile(dialog.FileName);
        }
        catch (Exception ex)
        {
            StatusMessage = $"Export failed: {ex.Message}";
            _logger.LogError(ex, "CSV export failed.");
        }
    }

    private async Task ExportHtmlAsync()
    {
        if (Results.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export storage metrics to HTML",
            Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
            DefaultExt = "html",
            FileName = "storage_metrics"
        };
        if (dialog.ShowDialog() != true) return;
        try
        {
            await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
            OpenFile(dialog.FileName);
        }
        catch (Exception ex)
        {
            StatusMessage = $"Export failed: {ex.Message}";
            _logger.LogError(ex, "HTML export failed.");
        }
    }

    private static void FlattenNode(StorageNode node, int level, List<StorageNode> result)
    {
        node.IndentLevel = level;
        result.Add(node);
        foreach (var child in node.Children)
            FlattenNode(child, level + 1, result);
    }

    private static void OpenFile(string filePath)
    {
        try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
        catch { /* ignore — file may open but this is best-effort */ }
    }
}
```

**Verification:**

```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```

Expected: 0 errors

### Task 2: Create StorageView XAML + code-behind, update DI and MainWindow wiring

**Files:**
- `SharepointToolbox/Views/Tabs/StorageView.xaml`
- `SharepointToolbox/Views/Tabs/StorageView.xaml.cs`
- `SharepointToolbox/Views/Converters/IndentConverter.cs` (create — also adds BytesConverter and InverseBoolConverter)
- `SharepointToolbox/App.xaml` (modify — register converters as Application.Resources)
- `SharepointToolbox/App.xaml.cs` (modify — add Storage registrations)
- `SharepointToolbox/MainWindow.xaml` (modify — add x:Name to Storage TabItem)
- `SharepointToolbox/MainWindow.xaml.cs` (modify — wire StorageTabItem.Content)

**Action:** Create / Modify

**Why:** STOR-01/02/03/04/05 — the UI that ties the storage service to user interaction.

```xml
<!-- SharepointToolbox/Views/Tabs/StorageView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.StorageView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization"
             xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
    <UserControl.Resources>
        <conv:IndentConverter x:Key="IndentConverter" />
    </UserControl.Resources>
    <DockPanel LastChildFill="True">
        <!-- Options panel -->
        <ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
                      Margin="8,8,4,8">
            <StackPanel>
                <!-- Site URL -->
                <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
                <TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
                         IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
                         Height="26" Margin="0,0,0,8"
                         ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.site.url]}" />

                <!-- Scan options group -->
                <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
                          Margin="0,0,0,8">
                    <StackPanel Margin="4">
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.per.lib]}"
                                  IsChecked="{Binding PerLibrary}" Margin="0,2" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.subsites]}"
                                  IsChecked="{Binding IncludeSubsites}" Margin="0,2" />
                        <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                            <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
                                   VerticalAlignment="Center" Padding="0,0,4,0" />
                            <TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
                                     Width="40" Height="22" VerticalAlignment="Center"
                                     IsEnabled="{Binding IsMaxDepth, Converter={StaticResource InverseBoolConverter}}" />
                        </StackPanel>
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
                                  IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
                        <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
                                   TextWrapping="Wrap" FontSize="11" Foreground="#888"
                                   Margin="0,6,0,0" />
                    </StackPanel>
                </GroupBox>

                <!-- Action buttons -->
                <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
                        Command="{Binding RunCommand}"
                        Height="28" Margin="0,0,0,4" />
                <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
                        Command="{Binding CancelCommand}"
                        Height="28" Margin="0,0,0,8" />

                <!-- Export group -->
                <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}"
                          Margin="0,0,0,8">
                    <StackPanel Margin="4">
                        <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}"
                                Command="{Binding ExportCsvCommand}"
                                Height="26" Margin="0,2" />
                        <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.html]}"
                                Command="{Binding ExportHtmlCommand}"
                                Height="26" Margin="0,2" />
                    </StackPanel>
                </GroupBox>

                <!-- Status -->
                <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
                           FontSize="11" Foreground="#555" Margin="0,4" />
            </StackPanel>
        </ScrollViewer>

        <!-- Results DataGrid -->
        <DataGrid x:Name="ResultsGrid"
                  ItemsSource="{Binding Results}"
                  IsReadOnly="True"
                  AutoGenerateColumns="False"
                  VirtualizingPanel.IsVirtualizing="True"
                  VirtualizingPanel.VirtualizationMode="Recycling"
                  Margin="4,8,8,8">
            <DataGrid.Columns>
                <DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.library]}"
                                        Width="*" MinWidth="160">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Name}"
                                       Margin="{Binding IndentLevel, Converter={StaticResource IndentConverter}}"
                                       VerticalAlignment="Center" />
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
                                    Binding="{Binding SiteTitle}" Width="140" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
                                    Binding="{Binding TotalFileCount, StringFormat=N0}"
                                    Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.size]}"
                                    Binding="{Binding TotalSizeBytes, Converter={StaticResource BytesConverter}}"
                                    Width="100" ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.versions]}"
                                    Binding="{Binding VersionSizeBytes, Converter={StaticResource BytesConverter}}"
                                    Width="110" ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.lastmod]}"
                                    Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}"
                                    Width="110" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
```

```csharp
// SharepointToolbox/Views/Tabs/StorageView.xaml.cs
using System.Windows.Controls;

namespace SharepointToolbox.Views.Tabs;

public partial class StorageView : UserControl
{
    public StorageView(ViewModels.Tabs.StorageViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}
```

The XAML references three resource converters. Create all three in a single file:

```csharp
// SharepointToolbox/Views/Converters/IndentConverter.cs
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace SharepointToolbox.Views.Converters;

/// <summary>Converts IndentLevel (int) to WPF Thickness for DataGrid indent.</summary>
[ValueConversion(typeof(int), typeof(Thickness))]
public class IndentConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        int level = value is int i ? i : 0;
        return new Thickness(level * 16, 0, 0, 0);
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotImplementedException();
}

/// <summary>Converts byte count (long) to human-readable size string.</summary>
[ValueConversion(typeof(long), typeof(string))]
public class BytesConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        long bytes = value is long l ? l : 0L;
        if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
        if (bytes >= 1_048_576L)     return $"{bytes / 1_048_576.0:F2} MB";
        if (bytes >= 1024L)          return $"{bytes / 1024.0:F2} KB";
        return $"{bytes} B";
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotImplementedException();
}

/// <summary>Inverts a bool binding — used to disable controls while an operation is running.</summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is bool b && !b;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is bool b && !b;
}
```

Register converters and styles in `App.xaml` `<Application.Resources>`. Check `App.xaml` first — if `InverseBoolConverter` was already added by a previous plan, do not duplicate it. Add whichever of these are missing:

```xml
<conv:IndentConverter      x:Key="IndentConverter" />
<conv:BytesConverter       x:Key="BytesConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
    <Setter Property="HorizontalAlignment" Value="Right" />
</Style>
```

Also ensure the `conv` xmlns is declared on the `Application` root element if not already present:
```xml
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
```

In `App.xaml.cs` `ConfigureServices`, add before existing Phase 2 registrations:
```csharp
// Phase 3: Storage
services.AddTransient<IStorageService, StorageService>();
services.AddTransient<StorageCsvExportService>();
services.AddTransient<StorageHtmlExportService>();
services.AddTransient<StorageViewModel>();
services.AddTransient<StorageView>();
```

In `MainWindow.xaml`, change the Storage TabItem from:
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
  <controls:FeatureTabBase />
</TabItem>
```
to:
```xml
<TabItem x:Name="StorageTabItem"
         Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
</TabItem>
```

In `MainWindow.xaml.cs`, add after the PermissionsTabItem wiring line:
```csharp
StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();
```

**Verification:**

```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -5
```

Expected: 0 build errors; all tests pass

## Verification

```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```

Expected: 0 errors. StorageView wired in MainWindow (grep: `StorageTabItem.Content`). StorageService registered in DI (grep: `IStorageService, StorageService`). `InverseBoolConverter` registered in App.xaml resources (grep: `InverseBoolConverter`).

## Commit Message
feat(03-07): create StorageViewModel, StorageView XAML, DI registration, and MainWindow wiring

## Output

After completion, create `.planning/phases/03-storage/03-07-SUMMARY.md`
