---
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<FeatureViewModelBase> _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<string>? _lastResult;

    public IAsyncRelayCommand ExportFailedCommand { get; }

    // Dialog factories — set by View code-behind
    public Func<TenantProfile, Window>? OpenSitePickerDialog { get; set; }
    public Func<Microsoft.SharePoint.Client.ClientContext, Views.Dialogs.FolderBrowserDialog>? OpenFolderBrowserDialog { get; set; }
    public Func<string, bool>? ShowConfirmDialog { get; set; }

    public TransferViewModel(
        IFileTransferService transferService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _transferService = transferService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> 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
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
             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 Margin="10">
        <!-- Options Panel (Left) -->
        <StackPanel DockPanel.Dock="Left" Width="340" Margin="0,0,10,0">
            <!-- Source -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <TextBox Text="{Binding SourceSiteUrl, UpdateSourceTrigger=PropertyChanged}"
                             IsReadOnly="True" Margin="0,0,0,5" />
                    <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
                            Click="BrowseSource_Click" Margin="0,0,0,5" />
                    <TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
                    <TextBlock Text="{Binding SourceFolderPath}" Foreground="Gray" />
                </StackPanel>
            </GroupBox>

            <!-- Destination -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.destsite]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <TextBox Text="{Binding DestSiteUrl, UpdateSourceTrigger=PropertyChanged}"
                             IsReadOnly="True" Margin="0,0,0,5" />
                    <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
                            Click="BrowseDest_Click" Margin="0,0,0,5" />
                    <TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
                    <TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" />
                </StackPanel>
            </GroupBox>

            <!-- Transfer Mode -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.copy]}"
                                 IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Copy}"
                                 Margin="0,0,0,3" />
                    <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.move]}"
                                 IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Move}" />
                </StackPanel>
            </GroupBox>

            <!-- Conflict Policy -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}"
                      Margin="0,0,0,10">
                <ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.rename]}" />
                </ComboBox>
            </GroupBox>

            <!-- Actions -->
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.start]}"
                    Command="{Binding RunCommand}" Margin="0,0,0,5"
                    IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
                    Command="{Binding CancelCommand}"
                    Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />

            <!-- Progress -->
            <ProgressBar Height="20" Margin="0,10,0,5"
                         Value="{Binding ProgressValue}"
                         Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
            <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />

            <!-- Results -->
            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5"
                       Visibility="{Binding ResultSummary, Converter={StaticResource StringToVisibilityConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}"
                    Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
        </StackPanel>

        <!-- Right panel placeholder for future enhancements -->
        <Border />
    </DockPanel>
</UserControl>
```

Note: The XAML uses converters (`InverseBoolConverter`, `BoolToVisibilityConverter`, `StringToVisibilityConverter`, `EnumBoolConverter`). If `EnumBoolConverter` or `StringToVisibilityConverter` don't already exist in the project, create them in `Views/Converters/` directory. The `InverseBoolConverter` and `BoolToVisibilityConverter` should already exist from Phase 1. If `BoolToVisibilityConverter` is not registered, use standard WPF `BooleanToVisibilityConverter`. If converters are not available, simplify the XAML to use code-behind visibility toggling instead.

Create `TransferView.xaml.cs`:
```csharp
using System.Windows;
using System.Windows.Controls;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class TransferView : UserControl
{
    private readonly ViewModels.Tabs.TransferViewModel _viewModel;
    private readonly ISessionManager _sessionManager;
    private readonly Func<TenantProfile, SitePickerDialog> _sitePickerFactory;

    public TransferView(
        ViewModels.Tabs.TransferViewModel viewModel,
        ISessionManager sessionManager,
        Func<TenantProfile, SitePickerDialog> sitePickerFactory)
    {
        InitializeComponent();
        _viewModel = viewModel;
        _sessionManager = sessionManager;
        _sitePickerFactory = sitePickerFactory;
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message) { Owner = Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }

    private async void BrowseSource_Click(object sender, RoutedEventArgs e)
    {
        if (_viewModel.CurrentProfile == null) return;

        // Pick site
        var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
        sitePicker.Owner = Window.GetWindow(this);
        if (sitePicker.ShowDialog() != true || sitePicker.SelectedSite == null) return;

        _viewModel.SourceSiteUrl = sitePicker.SelectedSite.Url;

        // Browse library/folder
        var profile = new TenantProfile
        {
            Name = _viewModel.CurrentProfile.Name,
            TenantUrl = sitePicker.SelectedSite.Url,
            ClientId = _viewModel.CurrentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
        var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
        if (folderBrowser.ShowDialog() == true)
        {
            _viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
            _viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
        }
    }

    private async void BrowseDest_Click(object sender, RoutedEventArgs e)
    {
        if (_viewModel.CurrentProfile == null) return;

        var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
        sitePicker.Owner = Window.GetWindow(this);
        if (sitePicker.ShowDialog() != true || sitePicker.SelectedSite == null) return;

        _viewModel.DestSiteUrl = sitePicker.SelectedSite.Url;

        var profile = new TenantProfile
        {
            Name = _viewModel.CurrentProfile.Name,
            TenantUrl = sitePicker.SelectedSite.Url,
            ClientId = _viewModel.CurrentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
        var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
        if (folderBrowser.ShowDialog() == true)
        {
            _viewModel.DestLibrary = folderBrowser.SelectedLibrary;
            _viewModel.DestFolderPath = folderBrowser.SelectedFolderPath;
        }
    }

    private void ConflictCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (ConflictCombo.SelectedIndex >= 0)
        {
            _viewModel.ConflictPolicy = (ConflictPolicy)ConflictCombo.SelectedIndex;
        }
    }

    // Expose CurrentProfile for site picker dialog
    private TenantProfile? CurrentProfile => _viewModel.CurrentProfile;
}
```

Note on `CurrentProfile`: The `TransferViewModel` needs to expose `_currentProfile` publicly (add `public TenantProfile? CurrentProfile => _currentProfile;` property to TransferViewModel, similar to StorageViewModel pattern).

**Verify:**
```bash
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
```

**Done:** TransferView compiles. Source/dest site pickers via SitePickerDialog, library/folder browsing via FolderBrowserDialog, Copy/Move radio buttons, conflict policy dropdown, confirmation dialog before start, progress tracking, failed-items export.

**Commit:** `feat(04-08): create TransferViewModel and TransferView`
