feat(01-06): build WPF shell — MainWindow XAML, ViewModels, LogPanelSink wiring
- Add FeatureTabBase UserControl with ProgressBar/TextBlock/CancelButton strip (Visibility bound to IsRunning, shown only during operations) - Add MainWindowViewModel with TenantProfiles ObservableCollection, ConnectCommand, ClearSessionCommand, ManageProfilesCommand, ProgressUpdatedMessage subscription - Add ProfileManagementViewModel wrapping ProfileService CRUD with input validation - Add SettingsViewModel (extends FeatureViewModelBase) with language/folder settings - Update MainWindow.xaml: DockPanel shell with Toolbar, TabControl (8 tabs), 150px RichTextBox LogPanel, StatusBar (tenant name | ProgressStatus | ProgressPercentage) - MainWindow.xaml.cs: DI constructor, DataContext=viewModel, LoadProfilesAsync on Loaded - App.xaml.cs: register all services, wire LogPanelSink after MainWindow resolved, register DispatcherUnhandledException and UnobservedTaskException global handlers - App.xaml: add BoolToVisibilityConverter resource
This commit is contained in:
127
SharepointToolbox/ViewModels/MainWindowViewModel.cs
Normal file
127
SharepointToolbox/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharepointToolbox.Core.Messages;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services;
|
||||
|
||||
namespace SharepointToolbox.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ObservableRecipient
|
||||
{
|
||||
private readonly ProfileService _profileService;
|
||||
private readonly SessionManager _sessionManager;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
[ObservableProperty]
|
||||
private TenantProfile? _selectedProfile;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _connectionStatus = "Not connected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _progressStatus = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _progressPercentage;
|
||||
|
||||
public ObservableCollection<TenantProfile> TenantProfiles { get; } = new();
|
||||
|
||||
public IAsyncRelayCommand ConnectCommand { get; }
|
||||
public IAsyncRelayCommand ClearSessionCommand { get; }
|
||||
public RelayCommand ManageProfilesCommand { get; }
|
||||
|
||||
public MainWindowViewModel(
|
||||
ProfileService profileService,
|
||||
SessionManager sessionManager,
|
||||
ILogger<MainWindowViewModel> logger)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_sessionManager = sessionManager;
|
||||
_logger = logger;
|
||||
|
||||
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
|
||||
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
|
||||
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
|
||||
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
protected override void OnActivated()
|
||||
{
|
||||
base.OnActivated();
|
||||
Messenger.Register<ProgressUpdatedMessage>(this, (r, m) =>
|
||||
{
|
||||
var vm = (MainWindowViewModel)r;
|
||||
vm.ProgressStatus = m.Value.Message;
|
||||
vm.ProgressPercentage = m.Value.Total > 0
|
||||
? (int)(100.0 * m.Value.Current / m.Value.Total)
|
||||
: 0;
|
||||
});
|
||||
}
|
||||
|
||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
|
||||
}
|
||||
ConnectCommand.NotifyCanExecuteChanged();
|
||||
ClearSessionCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
public async Task LoadProfilesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profiles = await _profileService.GetProfilesAsync();
|
||||
TenantProfiles.Clear();
|
||||
foreach (var profile in profiles)
|
||||
TenantProfiles.Add(profile);
|
||||
|
||||
if (TenantProfiles.Count > 0)
|
||||
SelectedProfile = TenantProfiles[0];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load tenant profiles.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
try
|
||||
{
|
||||
ConnectionStatus = "Connecting...";
|
||||
await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
|
||||
ConnectionStatus = SelectedProfile.Name;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionStatus = "Connection failed";
|
||||
_logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearSessionAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
try
|
||||
{
|
||||
await _sessionManager.ClearSessionAsync(SelectedProfile.TenantUrl);
|
||||
ConnectionStatus = "Not connected";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear session for {TenantUrl}.", SelectedProfile.TenantUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenProfileManagement()
|
||||
{
|
||||
// Profile management dialog opened by View layer (plan 01-07)
|
||||
}
|
||||
}
|
||||
125
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
Normal file
125
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services;
|
||||
|
||||
namespace SharepointToolbox.ViewModels;
|
||||
|
||||
public partial class ProfileManagementViewModel : ObservableObject
|
||||
{
|
||||
private readonly ProfileService _profileService;
|
||||
private readonly ILogger<ProfileManagementViewModel> _logger;
|
||||
|
||||
[ObservableProperty]
|
||||
private TenantProfile? _selectedProfile;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _newName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _newTenantUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _newClientId = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _validationMessage = string.Empty;
|
||||
|
||||
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
||||
|
||||
public IAsyncRelayCommand AddCommand { get; }
|
||||
public IAsyncRelayCommand RenameCommand { get; }
|
||||
public IAsyncRelayCommand DeleteCommand { get; }
|
||||
|
||||
public ProfileManagementViewModel(ProfileService profileService, ILogger<ProfileManagementViewModel> logger)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_logger = logger;
|
||||
|
||||
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
|
||||
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
|
||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profiles = await _profileService.GetProfilesAsync();
|
||||
Profiles.Clear();
|
||||
foreach (var p in profiles)
|
||||
Profiles.Add(p);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load profiles.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanAdd()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
||||
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
||||
if (string.IsNullOrWhiteSpace(NewClientId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
if (!CanAdd()) return;
|
||||
try
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = NewName.Trim(),
|
||||
TenantUrl = NewTenantUrl.Trim(),
|
||||
ClientId = NewClientId.Trim()
|
||||
};
|
||||
await _profileService.AddProfileAsync(profile);
|
||||
Profiles.Add(profile);
|
||||
NewName = string.Empty;
|
||||
NewTenantUrl = string.Empty;
|
||||
NewClientId = string.Empty;
|
||||
ValidationMessage = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to add profile.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenameAsync()
|
||||
{
|
||||
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
|
||||
try
|
||||
{
|
||||
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
|
||||
SelectedProfile.Name = NewName.Trim();
|
||||
NewName = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to rename profile.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
try
|
||||
{
|
||||
await _profileService.DeleteProfileAsync(SelectedProfile.Name);
|
||||
Profiles.Remove(SelectedProfile);
|
||||
SelectedProfile = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to delete profile.");
|
||||
}
|
||||
}
|
||||
}
|
||||
94
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
Normal file
94
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Win32;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
|
||||
namespace SharepointToolbox.ViewModels.Tabs;
|
||||
|
||||
public partial class SettingsViewModel : FeatureViewModelBase
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
private string _selectedLanguage = "en";
|
||||
public string SelectedLanguage
|
||||
{
|
||||
get => _selectedLanguage;
|
||||
set
|
||||
{
|
||||
if (_selectedLanguage == value) return;
|
||||
_selectedLanguage = value;
|
||||
OnPropertyChanged();
|
||||
_ = ApplyLanguageAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
private string _dataFolder = string.Empty;
|
||||
public string DataFolder
|
||||
{
|
||||
get => _dataFolder;
|
||||
set
|
||||
{
|
||||
if (_dataFolder == value) return;
|
||||
_dataFolder = value;
|
||||
OnPropertyChanged();
|
||||
_ = _settingsService.SetDataFolderAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand BrowseFolderCommand { get; }
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService, ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
BrowseFolderCommand = new RelayCommand(BrowseFolder);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
var settings = await _settingsService.GetSettingsAsync();
|
||||
_selectedLanguage = settings.Lang;
|
||||
_dataFolder = settings.DataFolder;
|
||||
OnPropertyChanged(nameof(SelectedLanguage));
|
||||
OnPropertyChanged(nameof(DataFolder));
|
||||
}
|
||||
|
||||
private async Task ApplyLanguageAsync(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
TranslationSource.Instance.CurrentCulture = new CultureInfo(code);
|
||||
await _settingsService.SetLanguageAsync(code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseFolder()
|
||||
{
|
||||
// OpenFolderDialog is available in .NET 8+ via Microsoft.Win32
|
||||
var dialog = new OpenFolderDialog
|
||||
{
|
||||
Title = "Select data output folder",
|
||||
InitialDirectory = string.IsNullOrEmpty(_dataFolder)
|
||||
? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
|
||||
: _dataFolder
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
DataFolder = dialog.FolderName;
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
// Settings tab has no long-running operation
|
||||
throw new NotSupportedException("Settings tab does not have a run operation.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user