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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo/AccountWizardDialog.xaml.cs b/gclo/AccountWizardDialog.xaml.cs
new file mode 100644
index 0000000..7929b94
--- /dev/null
+++ b/gclo/AccountWizardDialog.xaml.cs
@@ -0,0 +1,208 @@
+using System;
+using gclo.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace gclo
+{
+ ///
+ /// Four-step account wizard (identity, token, organization, destination) over an
+ /// . Next/Save and Back are the dialog's primary
+ /// and secondary buttons; both clicks are intercepted (deferral + args.Cancel)
+ /// so the dialog only closes on Cancel or after the final step saved successfully —
+ /// tells the caller which of the two happened.
+ ///
+ /// As with every ContentDialog, the caller must set XamlRoot before
+ /// ShowAsync.
+ ///
+ public sealed partial class AccountWizardDialog : ContentDialog
+ {
+ // A ContentDialog has no HWND of its own; the host window supplies one for pickers.
+ private readonly Func _windowHandleProvider;
+
+ /// Guards Next/Back against double activation while a step handler runs.
+ private bool _interactionInFlight;
+
+ /// The wizard state this dialog renders; target of every x:Bind.
+ public AccountWizardViewModel ViewModel { get; }
+
+ /// True once the account was saved; false when the dialog was dismissed.
+ public bool Saved { get; private set; }
+
+ ///
+ /// Wires the dialog to its (caller-owned) view model. The
+ /// returns the host window's HWND,
+ /// needed to initialize the folder picker on the destination step.
+ ///
+ public AccountWizardDialog(AccountWizardViewModel viewModel, Func windowHandleProvider)
+ {
+ ArgumentNullException.ThrowIfNull(viewModel);
+ ArgumentNullException.ThrowIfNull(windowHandleProvider);
+ ViewModel = viewModel;
+ _windowHandleProvider = windowHandleProvider;
+ InitializeComponent();
+
+ // PasswordBox has no reliable two-way binding: seed it with the token in
+ // effect (editing an account), and mirror edits back by hand. The seed's
+ // PasswordChanged echo writes the same value back, a no-op set.
+ if (ViewModel.Token.Length > 0)
+ {
+ TokenBox.Password = ViewModel.Token;
+ }
+
+ // NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged).
+ ConcurrencyBox.Value = ViewModel.MaxConcurrency;
+ }
+
+ // ---------------------------------------------------------------- x:Bind helpers
+
+ /// Shows a step panel only while it is the wizard's current step.
+ public Visibility StepVisible(int step, int current)
+ => step == current ? Visibility.Visible : Visibility.Collapsed;
+
+ /// Primary button caption: "Save" on the last step, "Next" before it.
+ public string PrimaryButtonLabel(int step) => step == 4 ? "Save" : "Next";
+
+ /// Back is available everywhere but the first step.
+ public bool BackEnabled(int step) => step > 1;
+
+ /// Next is locked while the token step is talking to GitHub.
+ public bool PrimaryEnabled(bool isValidatingToken) => !isValidatingToken;
+
+ ///
+ /// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
+ ///
+ public string OrgSubfolderLabel(string organization)
+ => string.IsNullOrWhiteSpace(organization)
+ ? "Create org subfolder"
+ : $"Create {organization.Trim()} subfolder";
+
+ // ---------------------------------------------------------------- buttons
+
+ ///
+ /// Next/Save: advances one validated step, or saves and closes on the last one.
+ /// The dialog stays open (args.Cancel) in every case except a successful save.
+ ///
+ private async void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (_interactionInFlight)
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ ContentDialogButtonClickDeferral deferral = args.GetDeferral();
+ _interactionInFlight = true;
+ try
+ {
+ // TryAdvanceAsync returns true on the last step without advancing;
+ // capture where we were to tell "advanced" from "ready to save".
+ bool wasLastStep = ViewModel.IsLastStep;
+ if (!await ViewModel.TryAdvanceAsync())
+ {
+ args.Cancel = true;
+ AnnounceStepError();
+ return;
+ }
+
+ if (!wasLastStep)
+ {
+ args.Cancel = true; // moved to the next step; keep the dialog open
+ return;
+ }
+
+ try
+ {
+ await ViewModel.SaveAsync();
+ Saved = true; // not canceled: the dialog closes now
+ }
+ catch (Exception ex)
+ {
+ // Duplicate name raced in, disk write failed, vault rejected the
+ // token, ... — surface it and let the user adjust or cancel.
+ args.Cancel = true;
+ SaveErrorBar.Message = ex.Message;
+ SaveErrorBar.IsOpen = true;
+ }
+ }
+ finally
+ {
+ _interactionInFlight = false;
+ deferral.Complete();
+ }
+ }
+
+ /// Back: never closes the dialog; steps the wizard back one page.
+ private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ args.Cancel = true;
+ if (!_interactionInFlight)
+ {
+ ViewModel.GoBack();
+ }
+ }
+
+ // ---------------------------------------------------------------- inputs
+
+ // PasswordBox does not support reliable two-way x:Bind on Password;
+ // mirror it into the view model by hand.
+ private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
+ => ViewModel.Token = ((PasswordBox)sender).Password;
+
+ /// Mirrors the NumberBox's double into the view model's int.
+ private void ConcurrencyBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
+ {
+ if (double.IsNaN(sender.Value))
+ {
+ // The box was cleared: push the canonical value back so it redisplays.
+ sender.Value = ViewModel.MaxConcurrency;
+ return;
+ }
+
+ ViewModel.MaxConcurrency = (int)Math.Clamp(
+ Math.Round(sender.Value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency);
+ }
+
+ private async void BrowseButton_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FolderPicker();
+ picker.FileTypeFilter.Add("*"); // required in packaged apps
+
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider());
+
+ var folder = await picker.PickSingleFolderAsync();
+ if (folder is not null)
+ {
+ ViewModel.TargetRoot = folder.Path;
+ }
+ }
+
+ // ---------------------------------------------------------------- announcements
+
+ ///
+ /// Raises LiveRegionChanged on the current step's error text after a failed
+ /// advance — LiveSetting alone does not announce: XAML never raises the event
+ /// automatically. Enqueued so the binding has pushed the new text first.
+ ///
+ private void AnnounceStepError()
+ {
+ TextBlock? errorText = ViewModel.Step switch
+ {
+ 1 => NameErrorText,
+ 2 => TokenErrorText,
+ _ => null, // steps 3 and 4 have no message property; the fields show what is required
+ };
+ if (errorText is null)
+ {
+ return;
+ }
+
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(errorText)
+ ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(errorText);
+ peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
+ });
+ }
+ }
+}
diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml
index 8bba641..cc2091b 100644
--- a/gclo/MainWindow.xaml
+++ b/gclo/MainWindow.xaml
@@ -60,6 +60,15 @@
+
+
+
+
+
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index bb25e43..a12a537 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using gclo.Engine;
using gclo.Services;
@@ -19,6 +20,9 @@ namespace gclo
/// and its ) is created
/// on first visit and cached, so switching away never interrupts a running sync;
/// pane badges surface running/failed state for the workspaces not on screen.
+ /// The pane's footer carries two commands: 'Sync all' (runs every account
+ /// sequentially via ) and 'Add account' (the
+ /// ); account items get an Edit/Delete context menu.
///
public sealed partial class MainWindow : Window
{
@@ -47,12 +51,19 @@ namespace gclo
/// Navigation items by workspace id, for badge and tooltip refreshes.
private readonly Dictionary _navItems = new();
- /// The workspace the pane currently rests on; where 'Add account' reverts to.
+ /// The workspace the pane currently rests on; where the footer commands revert to.
private Guid _currentWorkspaceId = Guid.Empty;
/// Suppresses re-entrant SelectionChanged while the code puts a selection back.
private bool _revertingSelection;
+ ///
+ /// Cancels the in-flight 'Sync all' queue; null while none runs. The token source
+ /// itself stays local to so its lifetime is
+ /// contained there — only the ability to cancel escapes.
+ ///
+ private Action? _cancelSyncAll;
+
public MainWindow()
{
_log = new FileActivityLog();
@@ -69,16 +80,9 @@ namespace gclo
_navItems[Guid.Empty] = QuickSyncNavItem;
foreach (Account account in _accountsStore.GetAll())
{
- var item = new NavigationViewItem
- {
- Content = account.Name,
- Tag = account.Id,
- Icon = new FontIcon { Glyph = "" },
- };
- ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
- _navItems[account.Id] = item;
- WorkspaceNav.MenuItems.Add(item);
+ WorkspaceNav.MenuItems.Add(CreateAccountNavItem(account));
}
+ UpdateSyncAllEnabled();
_settings = AppSettings.Load();
@@ -140,21 +144,17 @@ namespace gclo
if (item.Tag is "add")
{
- // The footer entry is a command, not a destination: put the selection
- // back on the current workspace, then show the stub dialog.
- _revertingSelection = true;
- try
- {
- WorkspaceNav.SelectedItem =
- _navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current)
- ? current
- : QuickSyncNavItem;
- }
- finally
- {
- _revertingSelection = false;
- }
- await ShowAddAccountStubAsync();
+ // The footer entries are commands, not destinations: put the selection
+ // back on the current workspace, then run the command.
+ RevertSelectionToCurrent();
+ await ShowAccountWizardAsync(existing: null);
+ return;
+ }
+
+ if (item.Tag is "syncall")
+ {
+ RevertSelectionToCurrent();
+ await ToggleSyncAllAsync();
return;
}
@@ -168,38 +168,73 @@ namespace gclo
}
}
+ ///
+ /// Puts the selection back on the current workspace after a footer command
+ /// (a command, not a destination) was activated.
+ ///
+ private void RevertSelectionToCurrent()
+ {
+ _revertingSelection = true;
+ try
+ {
+ WorkspaceNav.SelectedItem =
+ _navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current)
+ ? current
+ : QuickSyncNavItem;
+ }
+ finally
+ {
+ _revertingSelection = false;
+ }
+ }
+
///
/// Puts the workspace for on screen, creating and caching
/// it on first visit. Cached view models keep running syncs alive across switches.
///
private void ShowWorkspace(Guid id)
{
- if (!_workspaces.TryGetValue(id, out var workspace))
+ if (EnsureWorkspace(id) is { } workspace)
{
- WorkspaceViewModel? viewModel = CreateWorkspaceViewModel(id);
- if (viewModel is null)
- {
- return; // stale item: the account no longer exists in the store
- }
+ WorkspaceNav.Content = workspace.Page;
+ }
+ }
- var page = new WorkspacePage(
- viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this));
- workspace = (viewModel, page);
- _workspaces[id] = workspace;
-
- PropertyChangedEventHandler handler = (_, e) =>
- {
- if (e.PropertyName is nameof(WorkspaceViewModel.IsRunning)
- or nameof(WorkspaceViewModel.HasFailedRepos))
- {
- OnWorkspaceStateChanged(id);
- }
- };
- viewModel.PropertyChanged += handler;
- _badgeHandlers[id] = handler;
+ ///
+ /// Returns the cached workspace for , creating and caching
+ /// it (badge subscription included) on first use WITHOUT putting it on screen —
+ /// 'Sync all' warms unvisited workspaces this way. Null when the account no
+ /// longer exists in the store.
+ ///
+ private (WorkspaceViewModel ViewModel, WorkspacePage Page)? EnsureWorkspace(Guid id)
+ {
+ if (_workspaces.TryGetValue(id, out var workspace))
+ {
+ return workspace;
}
- WorkspaceNav.Content = workspace.Page;
+ WorkspaceViewModel? viewModel = CreateWorkspaceViewModel(id);
+ if (viewModel is null)
+ {
+ return null; // stale item: the account no longer exists in the store
+ }
+
+ var page = new WorkspacePage(
+ viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this));
+ workspace = (viewModel, page);
+ _workspaces[id] = workspace;
+
+ PropertyChangedEventHandler handler = (_, e) =>
+ {
+ if (e.PropertyName is nameof(WorkspaceViewModel.IsRunning)
+ or nameof(WorkspaceViewModel.HasFailedRepos))
+ {
+ OnWorkspaceStateChanged(id);
+ }
+ };
+ viewModel.PropertyChanged += handler;
+ _badgeHandlers[id] = handler;
+ return workspace;
}
private WorkspaceViewModel? CreateWorkspaceViewModel(Guid id)
@@ -273,26 +308,328 @@ namespace gclo
return badge;
}
- private async Task ShowAddAccountStubAsync()
+ // ---------------------------------------------------------------- accounts
+
+ ///
+ /// Builds the pane item for an account — name, icon, last-sync tooltip, and the
+ /// Edit/Delete context menu — and registers it for badge and tooltip refreshes.
+ ///
+ private NavigationViewItem CreateAccountNavItem(Account account)
{
- var dialog = new ContentDialog
+ var item = new NavigationViewItem
{
- Title = "Add account",
- Content = "The account setup wizard arrives in the next update. "
- + "Accounts created by future versions appear here automatically.",
- CloseButtonText = "OK",
- XamlRoot = Content.XamlRoot,
+ Content = account.Name,
+ Tag = account.Id,
+ Icon = new FontIcon { Glyph = "" },
+ ContextFlyout = CreateAccountContextFlyout(account.Id),
};
- await dialog.ShowAsync();
+ ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
+ _navItems[account.Id] = item;
+ return item;
+ }
+
+ /// The Edit/Delete context menu for an account's pane item.
+ private MenuFlyout CreateAccountContextFlyout(Guid id)
+ {
+ var edit = new MenuFlyoutItem
+ {
+ Text = "Edit…",
+ Icon = new FontIcon { Glyph = "" },
+ };
+ edit.Click += async (_, _) => await ShowEditAccountWizardAsync(id);
+
+ var delete = new MenuFlyoutItem
+ {
+ Text = "Delete…",
+ Icon = new FontIcon { Glyph = "" },
+ };
+ delete.Click += async (_, _) => await ConfirmDeleteAccountAsync(id);
+
+ var flyout = new MenuFlyout();
+ flyout.Items.Add(edit);
+ flyout.Items.Add(delete);
+ return flyout;
}
///
- /// Best effort on close: cancel in-flight git work in every cached workspace so
- /// clones are not killed mid-checkout (a killed clone is repaired on the next
- /// run), then dispose the view models and drop the badge subscriptions.
+ /// Inserts an account item into the pane's account section, keeping the section
+ /// sorted by name (case-insensitive) like .
+ /// Only Guid-tagged items other than Quick Sync are accounts; everything before
+ /// them (Quick Sync, the separator) is skipped by the tag check.
+ ///
+ private void InsertAccountNavItemSorted(NavigationViewItem item)
+ {
+ string name = item.Content as string ?? "";
+ int insertAt = WorkspaceNav.MenuItems.Count;
+ for (int i = 0; i < WorkspaceNav.MenuItems.Count; i++)
+ {
+ if (WorkspaceNav.MenuItems[i] is NavigationViewItem { Tag: Guid id, Content: string existing }
+ && id != Guid.Empty
+ && StringComparer.OrdinalIgnoreCase.Compare(name, existing) < 0)
+ {
+ insertAt = i;
+ break;
+ }
+ }
+ WorkspaceNav.MenuItems.Insert(insertAt, item);
+ }
+
+ ///
+ /// Runs the account wizard: adding when is null,
+ /// editing (seeded from the account and its vault token) otherwise. On save,
+ /// the pane is updated accordingly; on cancel nothing changes.
+ ///
+ private async Task ShowAccountWizardAsync(Account? existing)
+ {
+ string? existingToken = existing is null ? null : _tokenVault.TryRetrieve(existing.Id);
+ var viewModel = new AccountWizardViewModel(
+ _accountsStore, new GitHubOrganizationLister(), _settings, existing, existingToken);
+ var dialog = new AccountWizardDialog(
+ viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
+ {
+ XamlRoot = Content.XamlRoot,
+ };
+ await dialog.ShowAsync();
+ if (!dialog.Saved)
+ {
+ return;
+ }
+
+ if (existing is null)
+ {
+ OnAccountAdded(viewModel);
+ }
+ else
+ {
+ OnAccountEdited(existing.Id);
+ }
+ }
+
+ private async Task ShowEditAccountWizardAsync(Guid id)
+ {
+ Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
+ if (account is null)
+ {
+ return; // deleted meanwhile; the stale item is on its way out
+ }
+ await ShowAccountWizardAsync(account);
+ }
+
+ ///
+ /// After the wizard saved a new account: create its pane item (sorted into the
+ /// account section) and select it, which shows its workspace.
+ ///
+ private void OnAccountAdded(AccountWizardViewModel viewModel)
+ {
+ Account? account = _accountsStore.FindByName(viewModel.Name.Trim());
+ if (account is null)
+ {
+ _log.Error($"Account '{viewModel.Name}' was saved but cannot be found; restart to see it.");
+ return;
+ }
+
+ NavigationViewItem item = CreateAccountNavItem(account);
+ InsertAccountNavItemSorted(item);
+ UpdateSyncAllEnabled();
+ WorkspaceNav.SelectedItem = item; // SelectionChanged shows the new workspace
+ }
+
+ ///
+ /// After the wizard saved an edit: refresh the pane item and drop the cached
+ /// workspace — it still runs on the old profile — so the next visit (immediate,
+ /// when it is the current one) rebuilds it from the updated account.
+ ///
+ private void OnAccountEdited(Guid id)
+ {
+ Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
+ if (account is null)
+ {
+ return;
+ }
+
+ if (_navItems.TryGetValue(id, out NavigationViewItem? item))
+ {
+ item.Content = account.Name;
+ ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
+ }
+
+ if (_workspaces.ContainsKey(id))
+ {
+ EvictWorkspace(id);
+ if (_currentWorkspaceId == id)
+ {
+ ShowWorkspace(id);
+ }
+ }
+ }
+
+ ///
+ /// The context menu's Delete: confirms by name, then removes the account's
+ /// metadata and token, its pane item, and its cached workspace. Clones on disk
+ /// stay. Quick Sync takes over when the deleted account was on screen.
+ ///
+ private async Task ConfirmDeleteAccountAsync(Guid id)
+ {
+ Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
+ if (account is null)
+ {
+ return;
+ }
+
+ var confirm = new ContentDialog
+ {
+ Title = "Delete account",
+ Content = $"Delete '{account.Name}'? The saved profile and its stored token "
+ + "are removed. Repositories already cloned to disk are not touched.",
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = Content.XamlRoot,
+ };
+ if (await confirm.ShowAsync() != ContentDialogResult.Primary)
+ {
+ return;
+ }
+
+ try
+ {
+ _accountsStore.Delete(id);
+ }
+ catch (Exception ex)
+ {
+ _log.Error($"Deleting account '{account.Name}' failed: {ex.Message}", ex);
+ await ShowMessageAsync(
+ "Delete account", $"The account could not be deleted.\n{ex.Message}");
+ return;
+ }
+
+ EvictWorkspace(id);
+ if (_navItems.TryGetValue(id, out NavigationViewItem? item))
+ {
+ _navItems.Remove(id);
+ WorkspaceNav.MenuItems.Remove(item);
+ }
+ UpdateSyncAllEnabled();
+
+ if (_currentWorkspaceId == id)
+ {
+ _currentWorkspaceId = Guid.Empty;
+ WorkspaceNav.SelectedItem = QuickSyncNavItem; // SelectionChanged shows it
+ }
+ _log.Info($"Account '{account.Name}' deleted.");
+ }
+
+ ///
+ /// Removes a cached workspace: unhooks its badge subscription, cancels any
+ /// in-flight sync, disposes the view model, and clears its pane badge. The nav
+ /// item itself stays — delete removes it, edit rebuilds the workspace behind it.
+ ///
+ private void EvictWorkspace(Guid id)
+ {
+ if (!_workspaces.TryGetValue(id, out var workspace))
+ {
+ return;
+ }
+
+ if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler))
+ {
+ workspace.ViewModel.PropertyChanged -= handler;
+ _badgeHandlers.Remove(id);
+ }
+ if (workspace.ViewModel.SyncCancelCommand.CanExecute(null))
+ {
+ workspace.ViewModel.SyncCancelCommand.Execute(null);
+ }
+ workspace.ViewModel.Dispose();
+ _workspaces.Remove(id);
+
+ if (_navItems.TryGetValue(id, out NavigationViewItem? item))
+ {
+ item.InfoBadge = null;
+ }
+ }
+
+ // ---------------------------------------------------------------- sync all
+
+ ///
+ /// The 'Sync all' footer command: runs every account workspace sequentially in
+ /// pane order via , then shows a short summary.
+ /// A second activation while running cancels the queue after the in-flight
+ /// account (that account's own sync is left to finish).
+ ///
+ private async Task ToggleSyncAllAsync()
+ {
+ if (_cancelSyncAll is not null)
+ {
+ _cancelSyncAll();
+ return;
+ }
+
+ // Account workspaces in pane order, created (not shown) when never visited.
+ var accountWorkspaces = new List();
+ foreach (object menuItem in WorkspaceNav.MenuItems)
+ {
+ if (menuItem is NavigationViewItem { Tag: Guid id }
+ && id != Guid.Empty
+ && EnsureWorkspace(id) is { } workspace)
+ {
+ accountWorkspaces.Add(workspace.ViewModel);
+ }
+ }
+ if (accountWorkspaces.Count == 0)
+ {
+ return;
+ }
+
+ using var cts = new CancellationTokenSource();
+ _cancelSyncAll = cts.Cancel;
+ SyncAllNavItem.Content = "Cancel sync all";
+ try
+ {
+ SyncAllResult result =
+ await new SyncAllCoordinator(_log).RunAsync(accountWorkspaces, cts.Token);
+
+ string accounts = result.Ran == 1 ? "1 account" : $"{result.Ran} accounts";
+ string message = result.WasCanceled
+ ? $"Sync all was canceled: {accounts} synced, {result.Skipped} skipped."
+ : $"Sync all finished: {accounts} synced, {result.Skipped} skipped.";
+ await ShowMessageAsync("Sync all", message);
+ }
+ catch (Exception ex)
+ {
+ // Unexpected only: the coordinator reports per-account trouble by
+ // skipping. (Also lands here when the summary dialog cannot open
+ // because another ContentDialog is showing; the activity log carries
+ // the per-account outcomes either way.)
+ _log.Error($"Sync all did not finish cleanly: {ex.Message}", ex);
+ }
+ finally
+ {
+ _cancelSyncAll = null;
+ SyncAllNavItem.Content = "Sync all";
+ UpdateSyncAllEnabled();
+ }
+ }
+
+ ///
+ /// 'Sync all' is only actionable with at least one account — or while a run is
+ /// in flight, when the item must stay enabled so it can be canceled.
+ ///
+ private void UpdateSyncAllEnabled()
+ => SyncAllNavItem.IsEnabled =
+ _cancelSyncAll is not null || _navItems.Keys.Any(id => id != Guid.Empty);
+
+ ///
+ /// Best effort on close: stop the 'Sync all' queue, cancel in-flight git work in
+ /// every still-cached workspace (edits and deletes evict entries earlier, so this
+ /// covers whatever remains) so clones are not killed mid-checkout (a killed clone
+ /// is repaired on the next run), then dispose the view models and drop the badge
+ /// subscriptions.
///
private void DisposeWorkspaces()
{
+ _cancelSyncAll?.Invoke(); // no further accounts start while the window tears down
+
foreach ((Guid id, var workspace) in _workspaces)
{
if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler))
@@ -344,7 +681,7 @@ namespace gclo
{
if (!_updateService.IsSupported)
{
- await ShowUpdateMessageAsync(
+ await ShowMessageAsync(
"Check for updates",
"Updates are only available in installed builds.");
return;
@@ -353,7 +690,7 @@ namespace gclo
var result = await _updateService.CheckAsync();
if (result.Error is not null)
{
- await ShowUpdateMessageAsync(
+ await ShowMessageAsync(
"Check for updates",
$"Could not check for updates.\n{result.Error}");
return;
@@ -362,7 +699,7 @@ namespace gclo
if (result.AvailableVersion is null)
{
string current = _updateService.CurrentVersion is string v ? $" (v{v})" : "";
- await ShowUpdateMessageAsync("Check for updates", $"You are up to date{current}.");
+ await ShowMessageAsync("Check for updates", $"You are up to date{current}.");
return;
}
@@ -386,11 +723,11 @@ namespace gclo
string? error = await _updateService.DownloadAndApplyAsync();
if (error is not null)
{
- await ShowUpdateMessageAsync("Update failed", error);
+ await ShowMessageAsync("Update failed", error);
}
}
- private async Task ShowUpdateMessageAsync(string title, string message)
+ private async Task ShowMessageAsync(string title, string message)
{
var dialog = new ContentDialog
{