Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/03-storage/03-08-PLAN.md
Dev 655bb79a99
All checks were successful
Release zip package / release (push) Successful in 10s
chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:15:14 +02:00

37 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 08 SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint pending 4
03-05
03-06
03-07
SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
SharepointToolbox/Views/Tabs/SearchView.xaml
SharepointToolbox/Views/Tabs/SearchView.xaml.cs
SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
SharepointToolbox/Views/Tabs/DuplicatesView.xaml
SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
SharepointToolbox/App.xaml.cs
SharepointToolbox/MainWindow.xaml
SharepointToolbox/MainWindow.xaml.cs
false
SRCH-01
SRCH-02
SRCH-03
SRCH-04
DUPL-01
DUPL-02
DUPL-03
truths artifacts key_links
File Search tab shows filter controls (extensions, regex, date pickers, creator, editor, library, max results, site URL)
Running a file search populates the DataGrid with file name, extension, created, modified, author, modifier, size columns
Export CSV and Export HTML buttons are enabled after a successful search, disabled when results are empty
Duplicates tab shows type selector (Files/Folders), criteria checkboxes, site URL, optional library field, and Run Scan button
Running a duplicate scan populates the DataGrid with one row per DuplicateItem across all groups
Export HTML button is enabled after scan with results
All three feature tabs (Storage, File Search, Duplicates) are visible and functional in the running application
path provides exports
SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs File Search tab ViewModel
SearchViewModel
path provides
SharepointToolbox/Views/Tabs/SearchView.xaml File Search tab XAML
path provides exports
SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs Duplicates tab ViewModel
DuplicatesViewModel
path provides
SharepointToolbox/Views/Tabs/DuplicatesView.xaml Duplicates tab XAML
from to via pattern
SearchViewModel.cs ISearchService.SearchFilesAsync RunOperationAsync override SearchFilesAsync
from to via pattern
DuplicatesViewModel.cs IDuplicatesService.ScanDuplicatesAsync RunOperationAsync override ScanDuplicatesAsync
from to via pattern
App.xaml.cs ISearchService, SearchService DI registration ISearchService
from to via pattern
App.xaml.cs IDuplicatesService, DuplicatesService DI registration IDuplicatesService

Plan 03-08: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint

Goal

Create ViewModels and XAML Views for the File Search and Duplicates tabs, wire them into MainWindow, register all dependencies in App.xaml.cs, then pause for a visual checkpoint to verify all three Phase 3 tabs (Storage, File Search, Duplicates) are visible and functional in the running application.

Context

Plans 03-05 (export services), 03-06 (localization), and 03-07 (StorageView + DI) must complete first. The pattern established by StorageViewModel and PermissionsViewModel applies identically: FeatureViewModelBase, AsyncRelayCommand, Dispatcher.InvokeAsync for ObservableCollection updates, no stored ClientContext.

The Duplicates DataGrid flattens DuplicateGroup.Items into a flat list for display. Each row shows the group name, the individual item path, library, size, dates. A GroupName property on a display wrapper DTO is used to identify the group.

InverseBoolConverter, BytesConverter, and RightAlignStyle are registered in App.xaml by Plan 03-07. Both Search and Duplicates views use {StaticResource InverseBoolConverter} and {StaticResource BytesConverter} — these will resolve from Application.Resources.

Tasks

Task 1a: Create SearchViewModel, SearchView XAML, and SearchView code-behind

Files:

  • SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
  • SharepointToolbox/Views/Tabs/SearchView.xaml
  • SharepointToolbox/Views/Tabs/SearchView.xaml.cs

Action: Create

Why: SRCH-01 through SRCH-04 — the UI layer for file search.

// SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class SearchViewModel : FeatureViewModelBase
{
    private readonly ISearchService _searchService;
    private readonly ISessionManager _sessionManager;
    private readonly SearchCsvExportService _csvExportService;
    private readonly SearchHtmlExportService _htmlExportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;

    // ── Filter observable properties ─────────────────────────────────────────

    [ObservableProperty] private string _siteUrl = string.Empty;
    [ObservableProperty] private string _extensions = string.Empty;
    [ObservableProperty] private string _regex = string.Empty;
    [ObservableProperty] private bool _useCreatedAfter;
    [ObservableProperty] private DateTime _createdAfter = DateTime.Today.AddMonths(-1);
    [ObservableProperty] private bool _useCreatedBefore;
    [ObservableProperty] private DateTime _createdBefore = DateTime.Today;
    [ObservableProperty] private bool _useModifiedAfter;
    [ObservableProperty] private DateTime _modifiedAfter = DateTime.Today.AddMonths(-1);
    [ObservableProperty] private bool _useModifiedBefore;
    [ObservableProperty] private DateTime _modifiedBefore = DateTime.Today;
    [ObservableProperty] private string _createdBy = string.Empty;
    [ObservableProperty] private string _modifiedBy = string.Empty;
    [ObservableProperty] private string _library = string.Empty;
    [ObservableProperty] private int _maxResults = 5000;

    private ObservableCollection<SearchResult> _results = new();
    public ObservableCollection<SearchResult> 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 SearchViewModel(
        ISearchService searchService,
        ISessionManager sessionManager,
        SearchCsvExportService csvExportService,
        SearchHtmlExportService htmlExportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _searchService    = searchService;
        _sessionManager   = sessionManager;
        _csvExportService = csvExportService;
        _htmlExportService = htmlExportService;
        _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);
        ctx.Url = SiteUrl.TrimEnd('/');

        var opts = new SearchOptions(
            Extensions:    ParseExtensions(Extensions),
            Regex:         string.IsNullOrWhiteSpace(Regex) ? null : Regex,
            CreatedAfter:  UseCreatedAfter  ? CreatedAfter  : null,
            CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
            ModifiedAfter: UseModifiedAfter  ? ModifiedAfter  : null,
            ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
            CreatedBy:     string.IsNullOrWhiteSpace(CreatedBy)  ? null : CreatedBy,
            ModifiedBy:    string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
            Library:       string.IsNullOrWhiteSpace(Library)    ? null : Library,
            MaxResults:    Math.Clamp(MaxResults, 1, 50_000),
            SiteUrl:       SiteUrl.TrimEnd('/')
        );

        var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);

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

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

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

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

    private async Task ExportCsvAsync()
    {
        if (Results.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export search results to CSV",
            Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
            DefaultExt = "csv",
            FileName = "search_results"
        };
        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 search results to HTML",
            Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
            DefaultExt = "html",
            FileName = "search_results"
        };
        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 string[] ParseExtensions(string input)
    {
        if (string.IsNullOrWhiteSpace(input)) return Array.Empty<string>();
        return input.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
                    .Select(e => e.TrimStart('.').ToLowerInvariant())
                    .Where(e => e.Length > 0)
                    .Distinct()
                    .ToArray();
    }

    private static void OpenFile(string filePath)
    {
        try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
        catch { }
    }
}
<!-- SharepointToolbox/Views/Tabs/SearchView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel LastChildFill="True">
        <!-- Filters panel -->
        <ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
            <StackPanel>
                <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" />

                <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}"
                          Margin="0,0,0,8">
                    <StackPanel Margin="4">
                        <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
                        <TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
                                 ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />

                        <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0,0,0,2" />
                        <TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
                                 ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />

                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.after]}"
                                  IsChecked="{Binding UseCreatedAfter}" Margin="0,2" />
                        <DatePicker SelectedDate="{Binding CreatedAfter}"
                                    IsEnabled="{Binding UseCreatedAfter}" Height="26" Margin="0,0,0,4" />

                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.before]}"
                                  IsChecked="{Binding UseCreatedBefore}" Margin="0,2" />
                        <DatePicker SelectedDate="{Binding CreatedBefore}"
                                    IsEnabled="{Binding UseCreatedBefore}" Height="26" Margin="0,0,0,4" />

                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.after]}"
                                  IsChecked="{Binding UseModifiedAfter}" Margin="0,2" />
                        <DatePicker SelectedDate="{Binding ModifiedAfter}"
                                    IsEnabled="{Binding UseModifiedAfter}" Height="26" Margin="0,0,0,4" />

                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.before]}"
                                  IsChecked="{Binding UseModifiedBefore}" Margin="0,2" />
                        <DatePicker SelectedDate="{Binding ModifiedBefore}"
                                    IsEnabled="{Binding UseModifiedBefore}" Height="26" Margin="0,0,0,4" />

                        <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.created.by]}" Padding="0,0,0,2" />
                        <TextBox Text="{Binding CreatedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
                                 ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.created.by]}" Margin="0,0,0,6" />

                        <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.modified.by]}" Padding="0,0,0,2" />
                        <TextBox Text="{Binding ModifiedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
                                 ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.modified.by]}" Margin="0,0,0,6" />

                        <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" Padding="0,0,0,2" />
                        <TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
                                 ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.library]}" Margin="0,0,0,6" />

                        <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                            <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.max.results]}"
                                   VerticalAlignment="Center" Padding="0,0,4,0" />
                            <TextBox Text="{Binding MaxResults, UpdateSourceTrigger=PropertyChanged}"
                                     Width="60" Height="22" VerticalAlignment="Center" />
                        </StackPanel>
                    </StackPanel>
                </GroupBox>

                <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.search]}"
                        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" />

                <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=[srch.rad.csv]}"
                                Command="{Binding ExportCsvCommand}" Height="26" Margin="0,2" />
                        <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.rad.html]}"
                                Command="{Binding ExportHtmlCommand}" Height="26" Margin="0,2" />
                    </StackPanel>
                </GroupBox>

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

        <!-- Results DataGrid -->
        <DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
                  VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
            <DataGrid.Columns>
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.name]}"
                                    Binding="{Binding Title}" Width="180" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.ext]}"
                                    Binding="{Binding FileExtension}" Width="70" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.created]}"
                                    Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.author]}"
                                    Binding="{Binding Author}" Width="130" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modified]}"
                                    Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modby]}"
                                    Binding="{Binding ModifiedBy}" Width="130" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.size]}"
                                    Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
                                    Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.path]}"
                                    Binding="{Binding Path}" Width="*" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
// SharepointToolbox/Views/Tabs/SearchView.xaml.cs
using System.Windows.Controls;

namespace SharepointToolbox.Views.Tabs;

public partial class SearchView : UserControl
{
    public SearchView(ViewModels.Tabs.SearchViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

Verification:

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

Expected: 0 errors

Task 1b: Create DuplicatesViewModel, DuplicatesView XAML, and DuplicatesView code-behind

Files:

  • SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
  • SharepointToolbox/Views/Tabs/DuplicatesView.xaml
  • SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs

Action: Create

Why: DUPL-01 through DUPL-03 — the UI layer for duplicate detection.

// SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

/// <summary>Flat display row wrapping a DuplicateItem with its group name.</summary>
public class DuplicateRow
{
    public string GroupName { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Path { get; set; } = string.Empty;
    public string Library { get; set; } = string.Empty;
    public long? SizeBytes { get; set; }
    public DateTime? Created { get; set; }
    public DateTime? Modified { get; set; }
    public int? FolderCount { get; set; }
    public int? FileCount { get; set; }
    public int GroupSize { get; set; }
}

public partial class DuplicatesViewModel : FeatureViewModelBase
{
    private readonly IDuplicatesService _duplicatesService;
    private readonly ISessionManager _sessionManager;
    private readonly DuplicatesHtmlExportService _htmlExportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private IReadOnlyList<DuplicateGroup> _lastGroups = Array.Empty<DuplicateGroup>();

    [ObservableProperty] private string _siteUrl = string.Empty;
    [ObservableProperty] private bool _modeFiles = true;
    [ObservableProperty] private bool _modeFolders;
    [ObservableProperty] private bool _matchSize = true;
    [ObservableProperty] private bool _matchCreated;
    [ObservableProperty] private bool _matchModified;
    [ObservableProperty] private bool _matchSubfolders;
    [ObservableProperty] private bool _matchFileCount;
    [ObservableProperty] private bool _includeSubsites;
    [ObservableProperty] private string _library = string.Empty;

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

    public IAsyncRelayCommand ExportHtmlCommand { get; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public DuplicatesViewModel(
        IDuplicatesService duplicatesService,
        ISessionManager sessionManager,
        DuplicatesHtmlExportService htmlExportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _duplicatesService = duplicatesService;
        _sessionManager    = sessionManager;
        _htmlExportService = htmlExportService;
        _logger            = logger;

        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);
        ctx.Url = SiteUrl.TrimEnd('/');

        var opts = new DuplicateScanOptions(
            Mode:               ModeFiles ? "Files" : "Folders",
            MatchSize:          MatchSize,
            MatchCreated:       MatchCreated,
            MatchModified:      MatchModified,
            MatchSubfolderCount: MatchSubfolders,
            MatchFileCount:     MatchFileCount,
            IncludeSubsites:    IncludeSubsites,
            Library:            string.IsNullOrWhiteSpace(Library) ? null : Library
        );

        var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
        _lastGroups = groups;

        // Flatten groups to display rows
        var rows = groups
            .SelectMany(g => g.Items.Select(item => new DuplicateRow
            {
                GroupName   = g.Name,
                Name        = item.Name,
                Path        = item.Path,
                Library     = item.Library,
                SizeBytes   = item.SizeBytes,
                Created     = item.Created,
                Modified    = item.Modified,
                FolderCount = item.FolderCount,
                FileCount   = item.FileCount,
                GroupSize   = g.Items.Count
            }))
            .ToList();

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

    protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
    {
        _currentProfile = profile;
        Results = new ObservableCollection<DuplicateRow>();
        _lastGroups = Array.Empty<DuplicateGroup>();
        SiteUrl = string.Empty;
        OnPropertyChanged(nameof(CurrentProfile));
        ExportHtmlCommand.NotifyCanExecuteChanged();
    }

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

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

    private async Task ExportHtmlAsync()
    {
        if (_lastGroups.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export duplicates report to HTML",
            Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
            DefaultExt = "html",
            FileName = "duplicates_report"
        };
        if (dialog.ShowDialog() != true) return;
        try
        {
            await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
            Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
        }
        catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
    }
}
<!-- SharepointToolbox/Views/Tabs/DuplicatesView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel LastChildFill="True">
        <!-- Options panel -->
        <ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
            <StackPanel>
                <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" />

                <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.type]}" Margin="0,0,0,8">
                    <StackPanel Margin="4">
                        <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.files]}"
                                     IsChecked="{Binding ModeFiles}" Margin="0,2" />
                        <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.folders]}"
                                     IsChecked="{Binding ModeFolders}" Margin="0,2" />
                    </StackPanel>
                </GroupBox>

                <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
                    <StackPanel Margin="4">
                        <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
                                   TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,0,0,6" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
                                  IsChecked="{Binding MatchSize}" Margin="0,2" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
                                  IsChecked="{Binding MatchCreated}" Margin="0,2" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.modified]}"
                                  IsChecked="{Binding MatchModified}" Margin="0,2" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.subfolders]}"
                                  IsChecked="{Binding MatchSubfolders}" Margin="0,2" />
                        <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.filecount]}"
                                  IsChecked="{Binding MatchFileCount}" Margin="0,2" />
                    </StackPanel>
                </GroupBox>

                <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" />
                <TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
                         ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.dup.lib]}" Margin="0,0,0,6" />

                <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.include.subsites]}"
                          IsChecked="{Binding IncludeSubsites}" Margin="0,4,0,8" />

                <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.scan]}"
                        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" />

                <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.open.results]}"
                        Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />

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

        <!-- Results DataGrid -->
        <DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
                  VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
                <DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
                                    ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="160" />
                <DataGridTextColumn Header="Library" Binding="{Binding Library}" Width="120" />
                <DataGridTextColumn Header="Size"
                                    Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
                                    Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
                <DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
                <DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
                <DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
// SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
using System.Windows.Controls;

namespace SharepointToolbox.Views.Tabs;

public partial class DuplicatesView : UserControl
{
    public DuplicatesView(ViewModels.Tabs.DuplicatesViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

Verification:

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

Expected: 0 errors

Task 2: DI registration + MainWindow wiring + visual checkpoint

Files:

  • SharepointToolbox/App.xaml.cs (modify)
  • SharepointToolbox/MainWindow.xaml (modify)
  • SharepointToolbox/MainWindow.xaml.cs (modify)

Action: Modify

Why: Services must be registered; tabs must replace FeatureTabBase stubs; user must verify all three Phase 3 tabs are visible and functional.

In App.xaml.cs ConfigureServices, add after the Storage Phase 3 registrations:

// Phase 3: File Search
services.AddTransient<ISearchService, SearchService>();
services.AddTransient<SearchCsvExportService>();
services.AddTransient<SearchHtmlExportService>();
services.AddTransient<SearchViewModel>();
services.AddTransient<SearchView>();

// Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();

In MainWindow.xaml, add x:Name to the Search and Duplicates tab items:

<!-- Change from FeatureTabBase stubs to named TabItems -->
<TabItem x:Name="SearchTabItem"
         Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.search]}">
</TabItem>
<TabItem x:Name="DuplicatesTabItem"
         Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem>

In MainWindow.xaml.cs, add after the StorageTabItem wiring line:

SearchTabItem.Content     = serviceProvider.GetRequiredService<SearchView>();
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();

Visual Checkpoint — after build succeeds, launch the application and verify:

  1. The Storage tab shows the site URL input, scan options (Per-Library, Include Subsites, Folder Depth, Max Depth), Generate Metrics button, and an empty DataGrid
  2. The File Search tab shows the filter panel (Extensions, Name/Regex, date range checkboxes, Created By, Modified By, Library, Max Results) and the Run Search button
  3. The Duplicates tab shows the type selector (Files/Folders), criteria checkboxes, and Run Scan button
  4. Language switching (EN ↔ FR) updates all Phase 3 tab labels without restart

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

Expected: 0 build errors; all tests pass (no regressions from Phase 1/2)

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

Expected: 0 errors, all tests pass

Checkpoint

Type: checkpoint:human-verify

What was built: All three Phase 3 tabs (Storage, File Search, Duplicates) are wired into the running application. All Phase 3 services are registered in DI. All Phase 3 test suites pass.

How to verify:

  1. dotnet run --project C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/SharepointToolbox.csproj
  2. Confirm the Storage tab appears with site URL input and Generate Metrics button
  3. Confirm the File Search tab appears with filter controls and Run Search button
  4. Confirm the Duplicates tab appears with type selector and Run Scan button
  5. Switch language to French (Settings tab) — confirm Phase 3 tab headers and labels update
  6. Run the full test suite: dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x

Resume signal: Type "approved" when all six checks pass, or describe any issues found.

Commit Message

feat(03-08): create SearchViewModel, DuplicatesViewModel, XAML views, DI wiring — Phase 3 complete

Output

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