---
phase: 03
plan: 08
title: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint
status: pending
wave: 4
depends_on:
  - 03-05
  - 03-06
  - 03-07
files_modified:
  - 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
autonomous: false
requirements:
  - SRCH-01
  - SRCH-02
  - SRCH-03
  - SRCH-04
  - DUPL-01
  - DUPL-02
  - DUPL-03

must_haves:
  truths:
    - "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"
  artifacts:
    - path: "SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs"
      provides: "File Search tab ViewModel"
      exports: ["SearchViewModel"]
    - path: "SharepointToolbox/Views/Tabs/SearchView.xaml"
      provides: "File Search tab XAML"
    - path: "SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs"
      provides: "Duplicates tab ViewModel"
      exports: ["DuplicatesViewModel"]
    - path: "SharepointToolbox/Views/Tabs/DuplicatesView.xaml"
      provides: "Duplicates tab XAML"
  key_links:
    - from: "SearchViewModel.cs"
      to: "ISearchService.SearchFilesAsync"
      via: "RunOperationAsync override"
      pattern: "SearchFilesAsync"
    - from: "DuplicatesViewModel.cs"
      to: "IDuplicatesService.ScanDuplicatesAsync"
      via: "RunOperationAsync override"
      pattern: "ScanDuplicatesAsync"
    - from: "App.xaml.cs"
      to: "ISearchService, SearchService"
      via: "DI registration"
      pattern: "ISearchService"
    - from: "App.xaml.cs"
      to: "IDuplicatesService, DuplicatesService"
      via: "DI registration"
      pattern: "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.

```csharp
// 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 { }
    }
}
```

```xml
<!-- 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>
```

```csharp
// 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:**

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

```csharp
// 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."); }
    }
}
```

```xml
<!-- 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>
```

```csharp
// 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:**

```bash
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:
```csharp
// 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:
```xml
<!-- 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:
```csharp
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:**

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

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

## 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
```

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`
