From cafd694778f163d0fd1a8d043d7494fc678e745b Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 4 Jul 2026 16:53:02 -0500 Subject: [PATCH] Phase 4: account wizard, edit/delete flows, Sync All (#16) - Four-step account wizard (identity -> live-validated token -> org picker -> destination), used for both add and edit; edits preserve identity and sync history and only touch the vault when the token actually changed - Account nav items gain Edit/Delete context menus; delete confirms by name, removes the vault entry, and evicts the cached workspace - Sync all (nav footer): sequential run over every account workspace, auto-loading unloaded ones, skipping busy ones, with cancel-after- in-flight semantics and a summary dialog; the footer item flips to "Cancel sync all" while running - 27 new tests (201 total) Completes the accounts-mode arc from docs/plans/accounts-mode.md. Co-Authored-By: Claude Fable 5 --- .../AccountWizardViewModelTests.cs | 396 +++++++++++++++ gclo.Engine.Tests/SyncAllCoordinatorTests.cs | 264 ++++++++++ gclo.ViewModels/AccountWizardViewModel.cs | 243 +++++++++ gclo.ViewModels/SyncAllCoordinator.cs | 83 ++++ gclo/AccountWizardDialog.xaml | 147 ++++++ gclo/AccountWizardDialog.xaml.cs | 208 ++++++++ gclo/MainWindow.xaml | 9 + gclo/MainWindow.xaml.cs | 463 +++++++++++++++--- 8 files changed, 1750 insertions(+), 63 deletions(-) create mode 100644 gclo.Engine.Tests/AccountWizardViewModelTests.cs create mode 100644 gclo.Engine.Tests/SyncAllCoordinatorTests.cs create mode 100644 gclo.ViewModels/AccountWizardViewModel.cs create mode 100644 gclo.ViewModels/SyncAllCoordinator.cs create mode 100644 gclo/AccountWizardDialog.xaml create mode 100644 gclo/AccountWizardDialog.xaml.cs diff --git a/gclo.Engine.Tests/AccountWizardViewModelTests.cs b/gclo.Engine.Tests/AccountWizardViewModelTests.cs new file mode 100644 index 0000000..4c398fd --- /dev/null +++ b/gclo.Engine.Tests/AccountWizardViewModelTests.cs @@ -0,0 +1,396 @@ +using gclo.ViewModels; + +namespace gclo.Engine.Tests; + +/// +/// Tests for : step gating, token validation, +/// defaults-vs-edit seeding, and save semantics. Every wizard runs against a real +/// in a unique temp directory, an , +/// and a . +/// +public sealed class AccountWizardViewModelTests : IDisposable +{ + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + private readonly InMemoryVault _vault = new(); + private readonly FakeOrganizationLister _orgs = new(); + private readonly AccountsStore _store; + private readonly AppSettings _defaults = new() + { + DefaultTargetFolder = @"C:\repos-default", + DefaultMaxConcurrency = 6, + }; + + public AccountWizardViewModelTests() + { + _store = new AccountsStore(_vault, _root); + } + + public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root); + + private AccountWizardViewModel NewWizard(Account? existing = null, string? existingToken = null) + => new(_store, _orgs, _defaults, existing, existingToken); + + private static Account MakeAccount(string name) => new() + { + Id = Guid.NewGuid(), + Name = name, + Organization = "acme", + TargetRoot = @"C:\repos", + }; + + /// Advances until the wizard sits on , asserting each hop. + private static async Task AdvanceToStepAsync(AccountWizardViewModel wizard, int step) + { + while (wizard.Step < step) + { + int before = wizard.Step; + Assert.True(await wizard.TryAdvanceAsync(), $"expected to advance past step {before}"); + } + Assert.Equal(step, wizard.Step); + } + + /// A new-account wizard walked to the last step with valid inputs everywhere. + private async Task CreateWizardAtLastStepAsync() + { + var wizard = NewWizard(); + wizard.Name = "Work"; + wizard.Token = "ghp_token"; + await AdvanceToStepAsync(wizard, 3); + wizard.Organization = "acme"; + await AdvanceToStepAsync(wizard, 4); + return wizard; + } + + // ---------------------------------------------------------------- seeding + + [Fact] + public void NewWizard_StartsAtStepOne_SeededFromDefaults() + { + var wizard = NewWizard(); + + Assert.Equal(1, wizard.Step); + Assert.True(wizard.IsFirstStep); + Assert.False(wizard.IsLastStep); + Assert.False(wizard.IsEditing); + Assert.Equal("Add account", wizard.Title); + Assert.Equal("", wizard.Name); + Assert.Equal("", wizard.Description); + Assert.Equal("", wizard.Token); + Assert.Equal("", wizard.Organization); + Assert.Equal(@"C:\repos-default", wizard.TargetRoot); + Assert.False(wizard.CreateOrgSubfolder); + Assert.Equal(6, wizard.MaxConcurrency); + Assert.Equal("", wizard.NameError); + Assert.Equal("", wizard.TokenError); + } + + [Fact] + public void EditWizard_SeedsEveryFieldFromTheExistingAccount_AndItsToken() + { + var account = MakeAccount("Work") with + { + Description = "primary org", + Organization = "acme-inc", + TargetRoot = @"C:\work-repos", + CreateOrgSubfolder = true, + MaxConcurrency = 4, + }; + + var wizard = NewWizard(account, "ghp_original"); + + Assert.True(wizard.IsEditing); + Assert.Equal("Edit account", wizard.Title); + Assert.Equal("Work", wizard.Name); + Assert.Equal("primary org", wizard.Description); + Assert.Equal("ghp_original", wizard.Token); + Assert.Equal("acme-inc", wizard.Organization); + Assert.Equal(@"C:\work-repos", wizard.TargetRoot); + Assert.True(wizard.CreateOrgSubfolder); + Assert.Equal(4, wizard.MaxConcurrency); + } + + [Fact] + public void EditWizard_WithNoVaultToken_SeedsAnEmptyTokenBox() + { + var wizard = NewWizard(MakeAccount("Work"), existingToken: null); + + Assert.Equal("", wizard.Token); + } + + [Fact] + public void MaxConcurrency_IsClampedToTheSettingsRange() + { + var wizard = NewWizard(); + + wizard.MaxConcurrency = 0; + Assert.Equal(AppSettings.MinConcurrency, wizard.MaxConcurrency); + + wizard.MaxConcurrency = 1000; + Assert.Equal(AppSettings.MaxConcurrency, wizard.MaxConcurrency); + } + + // ---------------------------------------------------------------- step 1: identity + + [Fact] + public async Task TryAdvance_BlankName_SetsNameErrorAndStays() + { + var wizard = NewWizard(); + wizard.Name = " "; + + Assert.False(await wizard.TryAdvanceAsync()); + + Assert.Equal(1, wizard.Step); + Assert.NotEqual("", wizard.NameError); + } + + [Fact] + public async Task TryAdvance_DuplicateName_SetsNameErrorAndStays_ThenClearsOnceFixed() + { + _store.Save(MakeAccount("Work"), null); + var wizard = NewWizard(); + wizard.Name = "WORK"; // uniqueness is case-insensitive + + Assert.False(await wizard.TryAdvanceAsync()); + Assert.Equal(1, wizard.Step); + Assert.Contains("Work", wizard.NameError, StringComparison.Ordinal); + + wizard.Name = "Personal"; + Assert.True(await wizard.TryAdvanceAsync()); + Assert.Equal(2, wizard.Step); + Assert.Equal("", wizard.NameError); + } + + [Fact] + public async Task TryAdvance_EditKeepingItsOwnName_Advances() + { + var account = MakeAccount("Work"); + _store.Save(account, null); + var wizard = NewWizard(account, "ghp_original"); + + Assert.True(await wizard.TryAdvanceAsync()); + Assert.Equal(2, wizard.Step); + } + + [Fact] + public async Task TryAdvance_EditTakingAnotherAccountsName_IsBlocked() + { + var account = MakeAccount("Work"); + _store.Save(account, null); + _store.Save(MakeAccount("Personal"), null); + var wizard = NewWizard(account, "ghp_original"); + wizard.Name = "personal"; + + Assert.False(await wizard.TryAdvanceAsync()); + Assert.Equal(1, wizard.Step); + Assert.NotEqual("", wizard.NameError); + } + + // ---------------------------------------------------------------- step 2: token + + [Fact] + public async Task TryAdvance_AcceptedToken_FillsOrganizations_ClearsError_Advances() + { + string? validatedToken = null; + _orgs.Handler = (token, _) => + { + validatedToken = token; + return Task.FromResult>(new[] { "me", "acme" }); + }; + var wizard = NewWizard(); + wizard.Name = "Work"; + await AdvanceToStepAsync(wizard, 2); + wizard.Token = " ghp_valid "; + + Assert.True(await wizard.TryAdvanceAsync()); + + Assert.Equal(3, wizard.Step); + Assert.Equal("ghp_valid", validatedToken); // validated as it will be saved: trimmed + Assert.Equal(new[] { "me", "acme" }, wizard.Organizations); + Assert.Equal("", wizard.TokenError); + Assert.False(wizard.IsValidatingToken); + } + + [Fact] + public async Task TryAdvance_RejectedToken_SetsTokenErrorAndStays() + { + _orgs.Handler = (_, _) => throw new InvalidOperationException("GitHub rejected the token (401)."); + var wizard = NewWizard(); + wizard.Name = "Work"; + await AdvanceToStepAsync(wizard, 2); + wizard.Token = "ghp_bad"; + + Assert.False(await wizard.TryAdvanceAsync()); + + Assert.Equal(2, wizard.Step); + Assert.Equal("GitHub rejected the token (401).", wizard.TokenError); + Assert.False(wizard.IsValidatingToken); + } + + [Fact] + public async Task IsValidatingToken_IsTrueExactlyWhileTheLookupIsInFlight() + { + var gate = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + _orgs.Handler = (_, _) => gate.Task; + var wizard = NewWizard(); + wizard.Name = "Work"; + await AdvanceToStepAsync(wizard, 2); + wizard.Token = "ghp_slow"; + + Task advance = wizard.TryAdvanceAsync(); + Assert.True(wizard.IsValidatingToken); + + gate.SetResult(new[] { "me" }); + Assert.True(await advance); + Assert.False(wizard.IsValidatingToken); + Assert.Equal(3, wizard.Step); + } + + // ---------------------------------------------------------------- steps 3 + 4 + + [Fact] + public async Task TryAdvance_BlankOrganization_StaysOnStepThree() + { + var wizard = NewWizard(); + wizard.Name = "Work"; + wizard.Token = "ghp_token"; + await AdvanceToStepAsync(wizard, 3); + + Assert.False(await wizard.TryAdvanceAsync()); + Assert.Equal(3, wizard.Step); + + wizard.Organization = "acme"; + Assert.True(await wizard.TryAdvanceAsync()); + Assert.Equal(4, wizard.Step); + Assert.True(wizard.IsLastStep); + } + + [Fact] + public async Task TryAdvance_BlankTargetRoot_ReturnsFalseOnStepFour() + { + var wizard = await CreateWizardAtLastStepAsync(); + wizard.TargetRoot = " "; + + Assert.False(await wizard.TryAdvanceAsync()); + Assert.Equal(4, wizard.Step); + } + + [Fact] + public async Task TryAdvance_ValidStepFour_ReturnsTrueWithoutAdvancing() + { + var wizard = await CreateWizardAtLastStepAsync(); + + Assert.True(await wizard.TryAdvanceAsync()); + Assert.Equal(4, wizard.Step); // the host reacts by calling SaveAsync + } + + [Fact] + public async Task GoBack_StepsBackwards_AndIsANoOpOnStepOne() + { + var wizard = NewWizard(); + wizard.Name = "Work"; + wizard.Token = "ghp_token"; + await AdvanceToStepAsync(wizard, 2); + + wizard.GoBack(); + Assert.Equal(1, wizard.Step); + Assert.True(wizard.IsFirstStep); + + wizard.GoBack(); + Assert.Equal(1, wizard.Step); + } + + // ---------------------------------------------------------------- save: new accounts + + [Fact] + public async Task SaveAsync_NewAccount_PersistsTheAccountAndPutsTheTokenInTheVault() + { + var wizard = await CreateWizardAtLastStepAsync(); + wizard.Description = "primary org"; + wizard.CreateOrgSubfolder = true; + wizard.MaxConcurrency = 4; + wizard.TargetRoot = @"C:\work-repos"; + + await wizard.SaveAsync(); + + var saved = Assert.Single(_store.GetAll()); + Assert.Equal("Work", saved.Name); + Assert.Equal("primary org", saved.Description); + Assert.Equal("acme", saved.Organization); + Assert.Equal(@"C:\work-repos", saved.TargetRoot); + Assert.True(saved.CreateOrgSubfolder); + Assert.Equal(4, saved.MaxConcurrency); + Assert.Null(saved.LastSyncUtc); + Assert.Null(saved.LastSyncSummary); + Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id)); + } + + [Fact] + public async Task SaveAsync_TrimsEveryStringInput() + { + _orgs.Handler = (_, _) => Task.FromResult>(new[] { "acme" }); + var wizard = NewWizard(); + wizard.Name = " Work "; + wizard.Description = " primary org "; + wizard.Token = " ghp_token "; + await AdvanceToStepAsync(wizard, 3); + wizard.Organization = " acme "; + await AdvanceToStepAsync(wizard, 4); + wizard.TargetRoot = @" C:\work-repos "; + + await wizard.SaveAsync(); + + var saved = Assert.Single(_store.GetAll()); + Assert.Equal("Work", saved.Name); + Assert.Equal("primary org", saved.Description); + Assert.Equal("acme", saved.Organization); + Assert.Equal(@"C:\work-repos", saved.TargetRoot); + Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id)); + } + + // ---------------------------------------------------------------- save: edits + + [Fact] + public async Task SaveAsync_Edit_UpdatesFields_PreservesIdAndLastSync_LeavesVaultAlone() + { + var account = MakeAccount("Work") with + { + LastSyncUtc = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero), + LastSyncSummary = "Finished: 3 cloned.", + }; + _store.Save(account, "ghp_original"); + var wizard = NewWizard(account, "ghp_original"); + wizard.Description = "edited"; + wizard.Organization = "acme-2"; + await AdvanceToStepAsync(wizard, 4); + // Overwrite the vault entry out of band: if SaveAsync wrote the (unchanged) + // token back, this sentinel would be clobbered with 'ghp_original'. + _vault.Store(account.Id, "sentinel"); + + await wizard.SaveAsync(); + + var saved = Assert.Single(_store.GetAll()); + Assert.Equal(account.Id, saved.Id); + Assert.Equal("edited", saved.Description); + Assert.Equal("acme-2", saved.Organization); + Assert.Equal(account.LastSyncUtc, saved.LastSyncUtc); + Assert.Equal(account.LastSyncSummary, saved.LastSyncSummary); + Assert.Equal("sentinel", _vault.TryRetrieve(account.Id)); + } + + [Fact] + public async Task SaveAsync_Edit_WithAChangedToken_UpdatesTheVault() + { + var account = MakeAccount("Work"); + _store.Save(account, "ghp_original"); + var wizard = NewWizard(account, "ghp_original"); + wizard.Token = "ghp_rotated"; + await AdvanceToStepAsync(wizard, 4); + + await wizard.SaveAsync(); + + Assert.Equal(account.Id, Assert.Single(_store.GetAll()).Id); + Assert.Equal("ghp_rotated", _vault.TryRetrieve(account.Id)); + } +} diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs new file mode 100644 index 0000000..62af0db --- /dev/null +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -0,0 +1,264 @@ +using gclo.ViewModels; +using static gclo.Engine.Tests.GitTestHelpers; + +namespace gclo.Engine.Tests; + +/// +/// Tests for over fake-backed, account-seeded +/// s: strict sequential order, load-before-sync, +/// skip accounting, and queue-level cancellation that never touches the in-flight sync. +/// +public sealed class SyncAllCoordinatorTests : IDisposable +{ + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + private readonly InMemoryVault _vault = new(); + private readonly OrderedLister _lister = new(); + private readonly FakeOrganizationLister _orgs = new(); + private readonly List _workspaces = new(); + private readonly SyncAllCoordinator _coordinator = new(new NullActivityLog()); + + public void Dispose() + { + foreach (WorkspaceViewModel workspace in _workspaces) + { + workspace.Dispose(); + } + TryDeleteDirectory(_root); + } + + /// Synchronous progress: handler runs inline on the reporting thread. + private sealed class SyncProgress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + /// + /// One lister shared by every workspace, so the recorded organization order proves + /// globally which account loaded when. Behavior is scripted per organization. + /// + private sealed class OrderedLister : IRepositoryLister + { + private readonly List _organizations = new(); + private readonly object _gate = new(); + + /// Repositories (or exception) per organization. Default: one repo named '<org>-repo'. + public Func> Handler { get; set; } + = org => new[] { Repo(org + "-repo") }; + + /// Organizations listed so far, in call order. + public IReadOnlyList Organizations + { + get + { + lock (_gate) + { + return _organizations.ToArray(); + } + } + } + + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + lock (_gate) + { + _organizations.Add(organization); + } + try + { + return Task.FromResult(Handler(organization)); + } + catch (Exception ex) + { + return Task.FromException>(ex); + } + } + } + + /// + /// An account-backed workspace syncing organization 'org-<name>' into a folder + /// under the test root. The seeded vault token is deliberately shorter than a + /// plausible PAT so the constructor's debounced org lookup never fires. + /// + private WorkspaceViewModel CreateWorkspace(string name, FakeGitClient? git = null) + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = name, + Organization = "org-" + name, + TargetRoot = Path.Combine(_root, name), + }; + _vault.Store(account.Id, "tok-" + name); + + var workspace = new WorkspaceViewModel( + _lister, + git ?? new FakeGitClient(), + _orgs, + handler => new SyncProgress(handler), + TimeSpan.FromMilliseconds(1), + new NullActivityLog(), + account, + _vault); + _workspaces.Add(workspace); + return workspace; + } + + // ---------------------------------------------------------------- ordering + + [Fact] + public async Task RunAsync_RunsAccountsStrictlySequentially_InListOrder() + { + var gitAlpha = new FakeGitClient(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + gitAlpha.CloneHandler = (_, _, _, _, _) => + { + entered.TrySetResult(); + return gate.Task; + }; + var alpha = CreateWorkspace("alpha", gitAlpha); + var beta = CreateWorkspace("beta"); + + Task run = _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None); + + await entered.Task; // alpha's sync is in flight... + Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // ...and beta has not started + + gate.SetResult(); + SyncAllResult result = await run; + + Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false), result); + Assert.Equal(new[] { "org-alpha", "org-beta" }, _lister.Organizations); + } + + // ---------------------------------------------------------------- load-before-sync + + [Fact] + public async Task RunAsync_LoadsAnUnloadedWorkspace_ThenSyncsIt() + { + var git = new FakeGitClient(); + var workspace = CreateWorkspace("alpha", git); + Assert.False(workspace.HasLoadedRepos); + + SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None); + + Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result); + Assert.True(workspace.HasLoadedRepos); + Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames); + } + + [Fact] + public async Task RunAsync_DoesNotReloadAnAlreadyLoadedWorkspace() + { + var git = new FakeGitClient(); + var workspace = CreateWorkspace("alpha", git); + await workspace.LoadReposCommand.ExecuteAsync(null); + Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); + + SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None); + + Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result); + Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // still exactly one listing + Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames); + } + + // ---------------------------------------------------------------- skipping + + [Fact] + public async Task RunAsync_SkipsBusyWorkspaces_AndStillRunsTheRest() + { + var running = CreateWorkspace("alpha"); + running.IsRunning = true; + var loading = CreateWorkspace("beta"); + loading.IsLoadingRepos = true; + var resolving = CreateWorkspace("gamma"); + resolving.IsResolvingPaths = true; + var git = new FakeGitClient(); + var idle = CreateWorkspace("delta", git); + + SyncAllResult result = await _coordinator.RunAsync( + new[] { running, loading, resolving, idle }, CancellationToken.None); + + Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 3, WasCanceled: false), result); + Assert.Equal(new[] { "org-delta" }, _lister.Organizations); // busy ones were never even loaded + Assert.Equal(new[] { "org-delta-repo" }, git.ClonedRepoNames); + } + + [Fact] + public async Task RunAsync_SkipsAWorkspaceWhoseLoadFails_AndMovesOn() + { + _lister.Handler = org => org == "org-alpha" + ? throw new InvalidOperationException("org not found") + : new[] { Repo(org + "-repo") }; + var alpha = CreateWorkspace("alpha"); + var git = new FakeGitClient(); + var beta = CreateWorkspace("beta", git); + + SyncAllResult result = await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None); + + Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false), result); + Assert.False(alpha.HasLoadedRepos); + Assert.Equal(new[] { "org-beta-repo" }, git.ClonedRepoNames); + } + + [Fact] + public async Task RunAsync_SkipsALoadedWorkspaceWhoseSyncCannotRun() + { + _lister.Handler = _ => Array.Empty(); + var workspace = CreateWorkspace("alpha"); + + SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None); + + Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false), result); + Assert.True(workspace.HasLoadedRepos); // it loaded fine; there was just nothing to sync + } + + // ---------------------------------------------------------------- cancellation + + [Fact] + public async Task RunAsync_CancellationStopsTheQueue_ButLetsTheInFlightSyncFinish() + { + var gitAlpha = new FakeGitClient(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + gitAlpha.CloneHandler = (_, _, _, _, _) => + { + entered.TrySetResult(); + return gate.Task; + }; + var alpha = CreateWorkspace("alpha", gitAlpha); + var gitBeta = new FakeGitClient(); + var beta = CreateWorkspace("beta", gitBeta); + using var cts = new CancellationTokenSource(); + + Task run = _coordinator.RunAsync(new[] { alpha, beta }, cts.Token); + await entered.Task; + cts.Cancel(); // requested while alpha's sync is in flight... + gate.SetResult(); // ...which must still run to completion + SyncAllResult result = await run; + + Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true), result); + Assert.Equal(SyncStatus.Done, alpha.Repos[0].Status); // finished, not canceled + Assert.False(alpha.IsRunning); + Assert.False(beta.HasLoadedRepos); // the second account never started + Assert.Empty(gitBeta.CloneCalls); + Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); + } + + [Fact] + public async Task RunAsync_TokenAlreadyCanceled_StartsNothing() + { + var git = new FakeGitClient(); + var workspace = CreateWorkspace("alpha", git); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, cts.Token); + + Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: true), result); + Assert.False(workspace.HasLoadedRepos); + Assert.Empty(git.CloneCalls); + } +} diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs new file mode 100644 index 0000000..1a52c8a --- /dev/null +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -0,0 +1,243 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using gclo.Engine; + +namespace gclo.ViewModels; + +/// +/// Drives the four-step add/edit account wizard: identity (name + description), token +/// (validated by listing the organizations it can see), organization, and destination. +/// gates each step; persists the +/// result through the , writing the token to the vault only +/// when the user actually changed it (or always, for a new account). +/// +public sealed partial class AccountWizardViewModel : ObservableObject +{ + private readonly AccountsStore _store; + private readonly IOrganizationLister _orgLister; + private readonly Account? _existing; + + /// What the token box was seeded with; unchanged means "leave the vault alone". + private readonly string _seededToken; + + /// + /// A wizard for a new account seeded from , or — when + /// is given — an edit wizard seeded from that account + /// and (the vault's current token, or null when + /// the vault has no entry for it). + /// + public AccountWizardViewModel( + AccountsStore store, + IOrganizationLister orgLister, + AppSettings defaults, + Account? existing = null, + string? existingToken = null) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(orgLister); + ArgumentNullException.ThrowIfNull(defaults); + _store = store; + _orgLister = orgLister; + _existing = existing; + _seededToken = existingToken ?? ""; + + Step = 1; + NameError = ""; + TokenError = ""; + Token = _seededToken; + + if (existing is null) + { + Name = ""; + Description = ""; + Organization = ""; + TargetRoot = defaults.DefaultTargetFolder; + CreateOrgSubfolder = false; + MaxConcurrency = defaults.DefaultMaxConcurrency; + } + else + { + Name = existing.Name; + Description = existing.Description; + Organization = existing.Organization; + TargetRoot = existing.TargetRoot; + CreateOrgSubfolder = existing.CreateOrgSubfolder; + MaxConcurrency = existing.MaxConcurrency; + } + } + + /// Current wizard step, 1 (identity) through 4 (destination). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsFirstStep))] + [NotifyPropertyChangedFor(nameof(IsLastStep))] + public partial int Step { get; set; } + + /// Display name for the account; required and unique across accounts. + [ObservableProperty] + public partial string Name { get; set; } + + /// Optional free-form note about what the account is for. + [ObservableProperty] + public partial string Description { get; set; } + + /// GitHub personal access token; validated when leaving step 2. + [ObservableProperty] + public partial string Token { get; set; } + + /// Organization (or user) to sync; picked from or typed. + [ObservableProperty] + public partial string Organization { get; set; } + + /// Folder the sync targets; see . + [ObservableProperty] + public partial string TargetRoot { get; set; } + + /// When set, clones land under TargetRoot\Organization rather than TargetRoot. + [ObservableProperty] + public partial bool CreateOrgSubfolder { get; set; } + + /// Parallel clone/pull count; kept within the range. + [ObservableProperty] + public partial int MaxConcurrency { get; set; } + + /// True while step 2 is validating the token against the organization lister. + [ObservableProperty] + public partial bool IsValidatingToken { get; set; } + + /// Step 1's validation message; empty when the name is acceptable. + [ObservableProperty] + public partial string NameError { get; set; } + + /// Step 2's validation message; empty when the token was accepted. + [ObservableProperty] + public partial string TokenError { get; set; } + + /// Organizations the validated token can see; feeds step 3's editable dropdown. + public ObservableCollection Organizations { get; } = new(); + + /// True on step 1, where there is no step to go back to. + public bool IsFirstStep => Step == 1; + + /// True on step 4, where advancing means saving instead of moving on. + public bool IsLastStep => Step == 4; + + /// True when the wizard edits an existing account rather than creating one. + public bool IsEditing => _existing is not null; + + /// Dialog title matching the wizard's mode. + public string Title => IsEditing ? "Edit account" : "Add account"; + + partial void OnMaxConcurrencyChanged(int value) + { + int clamped = Math.Clamp(value, AppSettings.MinConcurrency, AppSettings.MaxConcurrency); + if (clamped != value) + { + MaxConcurrency = clamped; + } + } + + /// + /// Validates the current step. Steps 1-3 advance and return true on success; step 4 + /// returns true without advancing (the host then calls ). On + /// failure the wizard stays put, with the step's error message set where one exists + /// ( on step 1, on step 2). + /// + public async Task TryAdvanceAsync() + { + switch (Step) + { + case 1: + { + string name = Name.Trim(); + if (name.Length == 0) + { + NameError = "Enter a name for this account."; + return false; + } + Account? clash = _store.FindByName(name); + if (clash is not null && clash.Id != _existing?.Id) + { + NameError = $"An account named '{clash.Name}' already exists."; + return false; + } + NameError = ""; + Step = 2; + return true; + } + case 2: + { + // The lister is the validation: it fails on a rejected or rate-limited + // token and returns the organizations the dropdown offers otherwise. + IsValidatingToken = true; + try + { + var organizations = await _orgLister.ListOrganizationsAsync(Token.Trim()); + Organizations.Clear(); + foreach (string organization in organizations) + { + Organizations.Add(organization); + } + TokenError = ""; + } + catch (Exception ex) + { + TokenError = ex.Message; + return false; + } + finally + { + IsValidatingToken = false; + } + Step = 3; + return true; + } + case 3: + if (string.IsNullOrWhiteSpace(Organization)) + { + return false; // the dropdown allows free text, but not nothing + } + Step = 4; + return true; + default: + // Step 4: valid means "ready to save"; the host closes via SaveAsync. + return !string.IsNullOrWhiteSpace(TargetRoot); + } + } + + /// Returns to the previous step; a no-op on the first step. + public void GoBack() + { + if (!IsFirstStep) + { + Step--; + } + } + + /// + /// Persists the wizard's account: a new account gets a fresh id and always writes + /// its token to the vault; an edit keeps the existing id and last-sync fields and + /// touches the vault only when the token was changed. All string inputs are trimmed. + /// + public Task SaveAsync() + { + _store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null); + return Task.CompletedTask; + } + + /// New accounts always persist their token; edits only when it was altered. + private bool TokenChanged + => !IsEditing || !string.Equals(Token, _seededToken, StringComparison.Ordinal); + + private Account BuildAccount() => new() + { + Id = _existing?.Id ?? Guid.NewGuid(), + Name = Name.Trim(), + Description = Description.Trim(), + Organization = Organization.Trim(), + TargetRoot = TargetRoot.Trim(), + CreateOrgSubfolder = CreateOrgSubfolder, + MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency), + LastSyncUtc = _existing?.LastSyncUtc, + LastSyncSummary = _existing?.LastSyncSummary, + }; +} diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs new file mode 100644 index 0000000..f8b7113 --- /dev/null +++ b/gclo.ViewModels/SyncAllCoordinator.cs @@ -0,0 +1,83 @@ +using gclo.Engine; + +namespace gclo.ViewModels; + +/// +/// Outcome of one sync-all pass over the account workspaces. +/// +/// Accounts whose sync ran to completion (successfully or not). +/// Accounts skipped because they were busy, could not load, or had nothing to sync. +/// True when cancellation stopped the queue before every account was processed. +public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled); + +/// +/// Runs every account workspace's sync strictly one at a time, in the order given. +/// Workspaces that have not loaded their repositories yet are loaded first; busy or +/// unloadable workspaces are skipped rather than failing the pass. Cancellation is +/// cooperative and checked between accounts only: the in-flight account always +/// finishes, and just the remaining queue is abandoned. +/// +/// Sink for per-account start/finish/skip events. Never receives tokens. +public sealed class SyncAllCoordinator(IActivityLog log) +{ + private readonly IActivityLog _log = log ?? throw new ArgumentNullException(nameof(log)); + + /// + /// Syncs each workspace of in list order, + /// loading its repositories first when needed. A workspace is skipped (counted, + /// logged, never fatal) when it is already busy, when its repositories cannot be + /// loaded, or when its sync command cannot run. Canceling + /// stops the queue after the in-flight account rather than canceling its sync. + /// + public async Task RunAsync( + IReadOnlyList accountWorkspaces, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(accountWorkspaces); + + int ran = 0; + int skipped = 0; + + foreach (WorkspaceViewModel workspace in accountWorkspaces) + { + if (ct.IsCancellationRequested) + { + _log.Info("Sync all: canceled — remaining accounts were not started."); + return new SyncAllResult(ran, skipped, WasCanceled: true); + } + + if (workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths) + { + skipped++; + _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it is busy."); + continue; + } + + if (!workspace.HasLoadedRepos) + { + _log.Info($"Sync all: loading repositories for '{workspace.DisplayName}'."); + await workspace.LoadReposCommand.ExecuteAsync(null); + if (!workspace.HasLoadedRepos) + { + skipped++; + _log.Info( + $"Sync all: skipped '{workspace.DisplayName}' — its repositories could not be loaded."); + continue; + } + } + + if (!workspace.SyncCommand.CanExecute(null)) + { + skipped++; + _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it has nothing to sync."); + continue; + } + + _log.Info($"Sync all: syncing '{workspace.DisplayName}'."); + await workspace.SyncCommand.ExecuteAsync(null); + ran++; + _log.Info($"Sync all: finished '{workspace.DisplayName}'."); + } + + return new SyncAllResult(ran, skipped, WasCanceled: false); + } +} diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml new file mode 100644 index 0000000..8b90ddd --- /dev/null +++ b/gclo/AccountWizardDialog.xaml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +