diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs similarity index 78% rename from gclo.Engine.Tests/MainViewModelTests.cs rename to gclo.Engine.Tests/WorkspaceViewModelTests.cs index 22dbee8..95f858b 100644 --- a/gclo.Engine.Tests/MainViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -5,11 +5,11 @@ using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; /// -/// Headless tests for using a synchronous progress factory +/// Headless tests for using a synchronous progress factory /// (the production default, , posts asynchronously and would /// make assertions racy) and a near-zero org-lookup debounce. /// -public sealed class MainViewModelTests +public sealed class WorkspaceViewModelTests { private readonly FakeRepositoryLister _lister = new(); private readonly FakeGitClient _git = new(); @@ -21,18 +21,23 @@ public sealed class MainViewModelTests public void Report(RepoProgress value) => handler(value); } - private MainViewModel CreateViewModel(TimeSpan? debounce = null) + private WorkspaceViewModel CreateViewModel( + TimeSpan? debounce = null, + Account? account = null, + ITokenVault? vault = null, + AccountsStore? store = null) => new(_lister, _git, _orgs, handler => new SyncProgress(handler), debounce ?? TimeSpan.FromMilliseconds(1), - new NullActivityLog()); + new NullActivityLog(), + account, vault, store); /// /// A view model with valid inputs and the given repositories already loaded into the /// table. Waits out the token-triggered org lookup first so its status message cannot /// land on top of a later assertion. /// - private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos) + private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos) { _lister.Repositories = repos; var vm = CreateViewModel(); @@ -557,8 +562,8 @@ public sealed class MainViewModelTests Assert.Equal(@"C:\src\acme", vm.EffectiveTargetRoot); Assert.Equal(@"C:\src\acme\my-repo", vm.TargetPreview); - Assert.Contains(nameof(MainViewModel.EffectiveTargetRoot), raised); - Assert.Contains(nameof(MainViewModel.TargetPreview), raised); + Assert.Contains(nameof(WorkspaceViewModel.EffectiveTargetRoot), raised); + Assert.Contains(nameof(WorkspaceViewModel.TargetPreview), raised); } [Fact] @@ -668,6 +673,187 @@ public sealed class MainViewModelTests Assert.Equal(1, _orgs.Calls); } + // ---------------------------------------------------------------- account workspaces + + /// An account profile whose repositories land under . + private static Account MakeAccount(string targetRoot) => new() + { + Id = Guid.NewGuid(), + Name = "Work", + Organization = "acme", + TargetRoot = targetRoot, + MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads + }; + + [Fact] + public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Work", + Organization = "acme", + TargetRoot = @"C:\repos\work", + CreateOrgSubfolder = true, + MaxConcurrency = 4, + }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "vault-token-1234567890"); + string? lookedUpToken = null; + _orgs.Handler = (token, _) => + { + lookedUpToken = token; + return Task.FromResult>(["acme", "other"]); + }; + + var vm = CreateViewModel(account: account, vault: vault); + + Assert.Equal("acme", vm.Organization); + Assert.Equal(@"C:\repos\work", vm.TargetFolder); + Assert.True(vm.CreateOrgSubfolder); + Assert.Equal(4, vm.MaxConcurrency); + Assert.Equal("vault-token-1234567890", vm.Token); + Assert.Equal(account.Id, vm.AccountId); + Assert.Equal("Work", vm.DisplayName); + + await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire"); + Assert.Equal("vault-token-1234567890", lookedUpToken); + } + + [Fact] + public void QuickSyncConstruction_KeepsDefaults_AndQuickSyncIdentity() + { + var vm = CreateViewModel(); + + Assert.Null(vm.AccountId); + Assert.Equal("Quick Sync", vm.DisplayName); + Assert.Equal("", vm.Organization); + Assert.Equal("", vm.Token); + Assert.Equal("", vm.TargetFolder); + Assert.False(vm.CreateOrgSubfolder); + Assert.Equal(AppSettings.DefaultConcurrency, vm.MaxConcurrency); + Assert.False(vm.HasFailedRepos); + } + + [Fact] + public async Task HasFailedRepos_TurnsOnWithAFailure_AndOffWhenRetrySucceeds() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => + Path.GetFileName(path) == "bravo" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + Assert.False(vm.HasFailedRepos); + + await vm.SyncCommand.ExecuteAsync(null); + Assert.True(vm.HasFailedRepos); + + _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone + await vm.RetryFailedCommand.ExecuteAsync(null); + Assert.False(vm.HasFailedRepos); + + Directory.Delete(vm.TargetFolder, recursive: true); + } + + [Fact] + public async Task Sync_AccountWorkspace_RecordsTheResultOnTheStore() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); + var account = MakeAccount(Path.Combine(root, "repos")); + store.Save(account, "token-1234567890"); // the vault feeds the VM its token + _lister.Repositories = [Repo("alpha"), Repo("bravo")]; + + var vm = CreateViewModel(account: account, vault: vault, store: store); + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + await vm.LoadReposCommand.ExecuteAsync(null); + + await vm.SyncCommand.ExecuteAsync(null); + + Account recorded = Assert.Single(store.GetAll()); + Assert.NotNull(recorded.LastSyncUtc); + Assert.NotNull(recorded.LastSyncSummary); + Assert.StartsWith("Finished", recorded.LastSyncSummary); + Assert.Contains("2 cloned", recorded.LastSyncSummary); + Assert.Equal(vm.StatusText, recorded.LastSyncSummary); + } + finally + { + TryDeleteDirectory(root); + } + } + + [Fact] + public async Task Sync_QuickSync_DoesNotRecordOnTheStore() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); + store.Save(MakeAccount(Path.Combine(root, "repos")), "token-1234567890"); + _lister.Repositories = [Repo("alpha")]; + + // Quick Sync: the shared store exists, but no account backs this workspace. + var vm = CreateViewModel(vault: vault, store: store); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(root, "quick"); + vm.MaxConcurrencyValue = 1; + await vm.LoadReposCommand.ExecuteAsync(null); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Contains("1 cloned", vm.StatusText); + Account untouched = Assert.Single(store.GetAll()); + Assert.Null(untouched.LastSyncUtc); + Assert.Null(untouched.LastSyncSummary); + } + finally + { + TryDeleteDirectory(root); + } + } + + [Fact] + public async Task Sync_RecordSyncResultFailure_StillCompletesTheRun() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + string storeDir = Path.Combine(root, "store"); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, storeDir, new NullActivityLog()); + var account = MakeAccount(Path.Combine(root, "repos")); + store.Save(account, "token-1234567890"); + _lister.Repositories = [Repo("alpha")]; + + var vm = CreateViewModel(account: account, vault: vault, store: store); + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + await vm.LoadReposCommand.ExecuteAsync(null); + + // Break persistence: a file now squats on the store's directory path, so + // RecordSyncResult's write throws. The finished sync must shrug that off. + Directory.Delete(storeDir, recursive: true); + File.WriteAllText(storeDir, "not a directory"); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal("Finished: 1 cloned, 0 updated, 0 failed, 0 canceled of 1.", vm.StatusText); + Assert.True(vm.HasCompletedRun); + Assert.False(vm.IsRunning); + Assert.False(vm.HasFailedRepos); + } + finally + { + TryDeleteDirectory(root); + } + } + // ---------------------------------------------------------------- misc bindings [Theory] diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs similarity index 88% rename from gclo.ViewModels/MainViewModel.cs rename to gclo.ViewModels/WorkspaceViewModel.cs index 44797e7..43ad048 100644 --- a/gclo.ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -7,11 +7,14 @@ using gclo.Engine; namespace gclo.ViewModels; /// -/// Drives the org-sync UI in two phases: fills the repository -/// table, then clones or updates the selected subset with live -/// per-repo progress. re-runs just the failures. +/// Drives one workspace of the org-sync UI in two phases: +/// fills the repository table, then clones or updates the +/// selected subset with live per-repo progress. re-runs +/// just the failures. A workspace is either backed by a saved +/// (seeded from its profile, with each finished sync recorded back to the +/// ) or the ad-hoc Quick Sync mode when constructed without one. /// -public sealed partial class MainViewModel : ObservableObject, IDisposable +public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { private readonly IRepositoryLister _lister; private readonly IGitClient _git; @@ -19,6 +22,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable private readonly Func, IProgress> _progressFactory; private readonly TimeSpan _orgLookupDebounce; private readonly IActivityLog _log; + private readonly Account? _account; + private readonly AccountsStore? _accountsStore; private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _orgLoadCts; @@ -32,14 +37,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable /// Production dependencies by default; pass fakes for testing. The default progress /// factory is , which marshals via the SynchronizationContext /// captured at construction (the UI thread in the app); tests inject a synchronous one. + /// A non-null seeds the workspace from that profile — its + /// organization, target folder, subfolder preference, and concurrency — and pulls the + /// token from (the Token setter's org lookup fires + /// naturally); finished syncs are then recorded on . /// - public MainViewModel( + public WorkspaceViewModel( IRepositoryLister? lister = null, IGitClient? git = null, IOrganizationLister? orgLister = null, Func, IProgress>? progressFactory = null, TimeSpan? orgLookupDebounce = null, - IActivityLog? log = null) + IActivityLog? log = null, + Account? account = null, + ITokenVault? tokenVault = null, + AccountsStore? accountsStore = null) { _lister = lister ?? new GitHubRepositoryLister(); _git = git ?? new LibGit2GitClient(); @@ -47,14 +59,32 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable _progressFactory = progressFactory ?? (handler => new Progress(handler)); _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); _log = log ?? new FileActivityLog(); + _account = account; + _accountsStore = accountsStore; Organization = ""; Token = ""; TargetFolder = ""; MaxConcurrency = AppSettings.DefaultConcurrency; StatusText = ""; AllSelected = true; + + if (account is not null) + { + Organization = account.Organization; + TargetFolder = account.TargetRoot; + CreateOrgSubfolder = account.CreateOrgSubfolder; + MaxConcurrency = account.MaxConcurrency; + // The Token setter's existing org lookup fires naturally with the vault token. + Token = tokenVault?.TryRetrieve(account.Id) ?? ""; + } } + /// Id of the account this workspace was created for, or null for Quick Sync. + public Guid? AccountId => _account?.Id; + + /// Name shown for this workspace in navigation: the account's name, or "Quick Sync". + public string DisplayName => _account?.Name ?? "Quick Sync"; + public ObservableCollection Repos { get; } = new(); /// Organizations discovered from the current token; feeds the org dropdown. @@ -111,6 +141,10 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable [ObservableProperty] public partial bool HasCompletedRun { get; set; } + /// True while any row is in the Failed state; drives the navigation badge. + [ObservableProperty] + public partial bool HasFailedRepos { get; set; } + /// Column the table is currently sorted by, or null for load order. [ObservableProperty] public partial string? SortColumn { get; set; } @@ -372,6 +406,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable NotifyTargetPathChanged(); SyncCommand.NotifyCanExecuteChanged(); RetryFailedCommand.NotifyCanExecuteChanged(); + RecomputeHasFailedRepos(); } } @@ -419,6 +454,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; StatusText = summaryText; _log.Info(summaryText); + + if (_account is not null && _accountsStore is not null) + { + try + { + _accountsStore.RecordSyncResult(_account.Id, DateTimeOffset.UtcNow, summaryText); + } + catch (Exception ex) + { + // Bookkeeping only: failing to stamp the account must not turn a + // completed sync into an error. + _log.Error( + $"Failed to record the sync result for account '{_account.Name}': {ex.Message}", ex); + } + } } catch (OperationCanceledException) { @@ -609,5 +659,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable } } CompletedCount = completed; + RecomputeHasFailedRepos(); } + + private void RecomputeHasFailedRepos() + => HasFailedRepos = Repos.Any(r => r.Status == SyncStatus.Failed); } diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index 2e630ba..4970ea7 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -4,7 +4,6 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:gclo" - xmlns:vm="using:gclo.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" @@ -41,315 +40,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -