--- 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 _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 _results = new(); public ObservableCollection 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 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 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(items)); else Results = new ObservableCollection(items); } protected override void OnTenantSwitched(Core.Models.TenantProfile profile) { _currentProfile = profile; Results = new ObservableCollection(); 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(); 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