--- phase: 04 plan: 08 title: TransferViewModel + TransferView status: pending wave: 3 depends_on: - 04-03 - 04-07 files_modified: - SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs - SharepointToolbox/Views/Tabs/TransferView.xaml - SharepointToolbox/Views/Tabs/TransferView.xaml.cs autonomous: true requirements: - BULK-01 - BULK-04 - BULK-05 must_haves: truths: - "User can select source site via SitePickerDialog and browse source library/folder" - "User can select destination site and browse destination library/folder" - "User can choose Copy or Move mode and select conflict policy (Skip/Overwrite/Rename)" - "Confirmation dialog shown before transfer starts" - "Progress bar and cancel button work during transfer" - "After partial failure, user sees per-item results and can export failed items CSV" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs" provides: "Transfer tab ViewModel" exports: ["TransferViewModel"] - path: "SharepointToolbox/Views/Tabs/TransferView.xaml" provides: "Transfer tab XAML layout" - path: "SharepointToolbox/Views/Tabs/TransferView.xaml.cs" provides: "Transfer tab code-behind" key_links: - from: "TransferViewModel.cs" to: "IFileTransferService.TransferAsync" via: "RunOperationAsync override" pattern: "TransferAsync" - from: "TransferViewModel.cs" to: "ISessionManager.GetOrCreateContextAsync" via: "context acquisition for source and dest" pattern: "GetOrCreateContextAsync" - from: "TransferView.xaml" to: "TransferViewModel" via: "DataContext binding" pattern: "TransferViewModel" --- # Plan 04-08: TransferViewModel + TransferView ## Goal Create the `TransferViewModel` and `TransferView` for the file transfer tab. Source/destination site pickers (reusing SitePickerDialog pattern), library/folder tree browser (FolderBrowserDialog), Copy/Move toggle, conflict policy selector, progress tracking, cancellation, per-item error reporting, and failed-items CSV export. ## Context `IFileTransferService`, `TransferJob`, `ConflictPolicy`, `TransferMode` from Plan 04-01. `FileTransferService` implemented in Plan 04-03. `ConfirmBulkOperationDialog` and `FolderBrowserDialog` from Plan 04-07. Localization keys from Plan 04-07. ViewModel pattern: `FeatureViewModelBase` base class (RunCommand, CancelCommand, IsRunning, StatusMessage, ProgressValue). Override `RunOperationAsync`. Export commands as `IAsyncRelayCommand` with CanExport guard. Track `_currentProfile`, reset in `OnTenantSwitched`. View pattern: UserControl with DockPanel. Code-behind receives ViewModel from DI, sets DataContext. Wires dialog factories. ## Tasks ### Task 1: Create TransferViewModel **Files:** - `SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs` **Action:** ```csharp using System.Collections.ObjectModel; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using Serilog; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; namespace SharepointToolbox.ViewModels.Tabs; public partial class TransferViewModel : FeatureViewModelBase { private readonly IFileTransferService _transferService; private readonly ISessionManager _sessionManager; private readonly BulkResultCsvExportService _exportService; private readonly ILogger _logger; private TenantProfile? _currentProfile; // Source selection [ObservableProperty] private string _sourceSiteUrl = string.Empty; [ObservableProperty] private string _sourceLibrary = string.Empty; [ObservableProperty] private string _sourceFolderPath = string.Empty; // Destination selection [ObservableProperty] private string _destSiteUrl = string.Empty; [ObservableProperty] private string _destLibrary = string.Empty; [ObservableProperty] private string _destFolderPath = string.Empty; // Transfer options [ObservableProperty] private TransferMode _transferMode = TransferMode.Copy; [ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip; // Results [ObservableProperty] private string _resultSummary = string.Empty; [ObservableProperty] private bool _hasFailures; private BulkOperationSummary? _lastResult; public IAsyncRelayCommand ExportFailedCommand { get; } // Dialog factories — set by View code-behind public Func? OpenSitePickerDialog { get; set; } public Func? OpenFolderBrowserDialog { get; set; } public Func? ShowConfirmDialog { get; set; } public TransferViewModel( IFileTransferService transferService, ISessionManager sessionManager, BulkResultCsvExportService exportService, ILogger logger) : base(logger) { _transferService = transferService; _sessionManager = sessionManager; _exportService = exportService; _logger = logger; ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { if (_currentProfile == null) throw new InvalidOperationException("No tenant connected."); if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary)) throw new InvalidOperationException("Source site and library must be selected."); if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary)) throw new InvalidOperationException("Destination site and library must be selected."); // Confirmation dialog var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)"; if (ShowConfirmDialog != null && !ShowConfirmDialog(message)) return; var job = new TransferJob { SourceSiteUrl = SourceSiteUrl, SourceLibrary = SourceLibrary, SourceFolderPath = SourceFolderPath, DestinationSiteUrl = DestSiteUrl, DestinationLibrary = DestLibrary, DestinationFolderPath = DestFolderPath, Mode = TransferMode, ConflictPolicy = ConflictPolicy, }; // Get contexts for source and destination var srcProfile = new TenantProfile { Name = _currentProfile.Name, TenantUrl = SourceSiteUrl, ClientId = _currentProfile.ClientId, }; var dstProfile = new TenantProfile { Name = _currentProfile.Name, TenantUrl = DestSiteUrl, ClientId = _currentProfile.ClientId, }; var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct); var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct); _lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); // Update UI on dispatcher await Application.Current.Dispatcher.InvokeAsync(() => { HasFailures = _lastResult.HasFailures; ExportFailedCommand.NotifyCanExecuteChanged(); if (_lastResult.HasFailures) { ResultSummary = string.Format( Localization.TranslationSource.Instance["bulk.result.success"], _lastResult.SuccessCount, _lastResult.FailedCount); } else { ResultSummary = string.Format( Localization.TranslationSource.Instance["bulk.result.allsuccess"], _lastResult.TotalCount); } }); } private async Task ExportFailedAsync() { if (_lastResult == null || !_lastResult.HasFailures) return; var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "transfer_failed_items.csv", }; if (dlg.ShowDialog() == true) { await _exportService.WriteFailedItemsCsvAsync( _lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None); Log.Information("Exported failed transfer items to {Path}", dlg.FileName); } } protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; SourceSiteUrl = string.Empty; SourceLibrary = string.Empty; SourceFolderPath = string.Empty; DestSiteUrl = string.Empty; DestLibrary = string.Empty; DestFolderPath = string.Empty; ResultSummary = string.Empty; HasFailures = false; _lastResult = null; } } ``` **Verify:** ```bash dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q ``` **Done:** TransferViewModel compiles with source/dest selection, transfer mode, conflict policy, confirmation dialog, per-item results, and failed-items export. ### Task 2: Create TransferView XAML + code-behind **Files:** - `SharepointToolbox/Views/Tabs/TransferView.xaml` - `SharepointToolbox/Views/Tabs/TransferView.xaml.cs` **Action:** Create `TransferView.xaml`: ```xml