Files
Sharepoint-Toolbox/.planning/phases/03-storage/03-07-PLAN.md
Dev 43dd6ce17f docs(03-08): complete SearchViewModel + DuplicatesViewModel + Views plan — Phase 3 complete
- 3 tasks completed, 9 files created/modified
- Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
2026-04-02 16:10:54 +02:00

23 KiB

phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan title status wave depends_on files_modified autonomous requirements must_haves
03 07 StorageViewModel + StorageView XAML + DI Wiring pending 3
03-03
03-06
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
true
STOR-01
STOR-02
STOR-03
STOR-04
STOR-05
truths artifacts key_links
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
path provides exports
SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs Storage tab ViewModel (IStorageService orchestration)
StorageViewModel
path provides
SharepointToolbox/Views/Tabs/StorageView.xaml Storage tab XAML (DataGrid + controls)
path provides
SharepointToolbox/Views/Tabs/StorageView.xaml.cs StorageView code-behind
from to via pattern
StorageViewModel.cs IStorageService.CollectStorageAsync RunOperationAsync override CollectStorageAsync
from to via pattern
StorageViewModel.cs ISessionManager.GetOrCreateContextAsync context acquisition GetOrCreateContextAsync
from to via pattern
StorageView.xaml StorageViewModel.Results DataGrid ItemsSource binding 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:

<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.

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:

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.

<!-- 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>
// 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:

// 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:

<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:

xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"

In App.xaml.cs ConfigureServices, add before existing Phase 2 registrations:

// 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:

<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
  <controls:FeatureTabBase />
</TabItem>

to:

<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:

StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();

Verification:

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

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