Phase 4: account wizard, edit/delete, Sync All #28

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-04 21:57:38 +00:00
8 changed files with 1750 additions and 63 deletions

No files matched your search

@@ -0,0 +1,396 @@
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="AccountWizardViewModel"/>: step gating, token validation,
/// defaults-vs-edit seeding, and save semantics. Every wizard runs against a real
/// <see cref="AccountsStore"/> in a unique temp directory, an <see cref="InMemoryVault"/>,
/// and a <see cref="FakeOrganizationLister"/>.
/// </summary>
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",
};
/// <summary>Advances until the wizard sits on <paramref name="step"/>, asserting each hop.</summary>
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);
}
/// <summary>A new-account wizard walked to the last step with valid inputs everywhere.</summary>
private async Task<AccountWizardViewModel> 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<IReadOnlyList<string>>(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<IReadOnlyList<string>>(
TaskCreationOptions.RunContinuationsAsynchronously);
_orgs.Handler = (_, _) => gate.Task;
var wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "ghp_slow";
Task<bool> 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<IReadOnlyList<string>>(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));
}
}
@@ -0,0 +1,264 @@
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="SyncAllCoordinator"/> over fake-backed, account-seeded
/// <see cref="WorkspaceViewModel"/>s: strict sequential order, load-before-sync,
/// skip accounting, and queue-level cancellation that never touches the in-flight sync.
/// </summary>
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<WorkspaceViewModel> _workspaces = new();
private readonly SyncAllCoordinator _coordinator = new(new NullActivityLog());
public void Dispose()
{
foreach (WorkspaceViewModel workspace in _workspaces)
{
workspace.Dispose();
}
TryDeleteDirectory(_root);
}
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
/// <summary>
/// One lister shared by every workspace, so the recorded organization order proves
/// globally which account loaded when. Behavior is scripted per organization.
/// </summary>
private sealed class OrderedLister : IRepositoryLister
{
private readonly List<string> _organizations = new();
private readonly object _gate = new();
/// <summary>Repositories (or exception) per organization. Default: one repo named '&lt;org&gt;-repo'.</summary>
public Func<string, IReadOnlyList<RepoDescriptor>> Handler { get; set; }
= org => new[] { Repo(org + "-repo") };
/// <summary>Organizations listed so far, in call order.</summary>
public IReadOnlyList<string> Organizations
{
get
{
lock (_gate)
{
return _organizations.ToArray();
}
}
}
public Task<IReadOnlyList<RepoDescriptor>> 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<IReadOnlyList<RepoDescriptor>>(ex);
}
}
}
/// <summary>
/// An account-backed workspace syncing organization 'org-&lt;name&gt;' 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.
/// </summary>
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<SyncAllResult> 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<RepoDescriptor>();
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<SyncAllResult> 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);
}
}
+243
View File
@@ -0,0 +1,243 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Drives the four-step add/edit account wizard: identity (name + description), token
/// (validated by listing the organizations it can see), organization, and destination.
/// <see cref="TryAdvanceAsync"/> gates each step; <see cref="SaveAsync"/> persists the
/// result through the <see cref="AccountsStore"/>, writing the token to the vault only
/// when the user actually changed it (or always, for a new account).
/// </summary>
public sealed partial class AccountWizardViewModel : ObservableObject
{
private readonly AccountsStore _store;
private readonly IOrganizationLister _orgLister;
private readonly Account? _existing;
/// <summary>What the token box was seeded with; unchanged means "leave the vault alone".</summary>
private readonly string _seededToken;
/// <summary>
/// A wizard for a new account seeded from <paramref name="defaults"/>, or — when
/// <paramref name="existing"/> is given — an edit wizard seeded from that account
/// and <paramref name="existingToken"/> (the vault's current token, or null when
/// the vault has no entry for it).
/// </summary>
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;
}
}
/// <summary>Current wizard step, 1 (identity) through 4 (destination).</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsFirstStep))]
[NotifyPropertyChangedFor(nameof(IsLastStep))]
public partial int Step { get; set; }
/// <summary>Display name for the account; required and unique across accounts.</summary>
[ObservableProperty]
public partial string Name { get; set; }
/// <summary>Optional free-form note about what the account is for.</summary>
[ObservableProperty]
public partial string Description { get; set; }
/// <summary>GitHub personal access token; validated when leaving step 2.</summary>
[ObservableProperty]
public partial string Token { get; set; }
/// <summary>Organization (or user) to sync; picked from <see cref="Organizations"/> or typed.</summary>
[ObservableProperty]
public partial string Organization { get; set; }
/// <summary>Folder the sync targets; see <see cref="CreateOrgSubfolder"/>.</summary>
[ObservableProperty]
public partial string TargetRoot { get; set; }
/// <summary>When set, clones land under TargetRoot\Organization rather than TargetRoot.</summary>
[ObservableProperty]
public partial bool CreateOrgSubfolder { get; set; }
/// <summary>Parallel clone/pull count; kept within the <see cref="AppSettings"/> range.</summary>
[ObservableProperty]
public partial int MaxConcurrency { get; set; }
/// <summary>True while step 2 is validating the token against the organization lister.</summary>
[ObservableProperty]
public partial bool IsValidatingToken { get; set; }
/// <summary>Step 1's validation message; empty when the name is acceptable.</summary>
[ObservableProperty]
public partial string NameError { get; set; }
/// <summary>Step 2's validation message; empty when the token was accepted.</summary>
[ObservableProperty]
public partial string TokenError { get; set; }
/// <summary>Organizations the validated token can see; feeds step 3's editable dropdown.</summary>
public ObservableCollection<string> Organizations { get; } = new();
/// <summary>True on step 1, where there is no step to go back to.</summary>
public bool IsFirstStep => Step == 1;
/// <summary>True on step 4, where advancing means saving instead of moving on.</summary>
public bool IsLastStep => Step == 4;
/// <summary>True when the wizard edits an existing account rather than creating one.</summary>
public bool IsEditing => _existing is not null;
/// <summary>Dialog title matching the wizard's mode.</summary>
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;
}
}
/// <summary>
/// Validates the current step. Steps 1-3 advance and return true on success; step 4
/// returns true without advancing (the host then calls <see cref="SaveAsync"/>). On
/// failure the wizard stays put, with the step's error message set where one exists
/// (<see cref="NameError"/> on step 1, <see cref="TokenError"/> on step 2).
/// </summary>
public async Task<bool> 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);
}
}
/// <summary>Returns to the previous step; a no-op on the first step.</summary>
public void GoBack()
{
if (!IsFirstStep)
{
Step--;
}
}
/// <summary>
/// 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.
/// </summary>
public Task SaveAsync()
{
_store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null);
return Task.CompletedTask;
}
/// <summary>New accounts always persist their token; edits only when it was altered.</summary>
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,
};
}
+83
View File
@@ -0,0 +1,83 @@
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Outcome of one sync-all pass over the account workspaces.
/// </summary>
/// <param name="Ran">Accounts whose sync ran to completion (successfully or not).</param>
/// <param name="Skipped">Accounts skipped because they were busy, could not load, or had nothing to sync.</param>
/// <param name="WasCanceled">True when cancellation stopped the queue before every account was processed.</param>
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled);
/// <summary>
/// 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.
/// </summary>
/// <param name="log">Sink for per-account start/finish/skip events. Never receives tokens.</param>
public sealed class SyncAllCoordinator(IActivityLog log)
{
private readonly IActivityLog _log = log ?? throw new ArgumentNullException(nameof(log));
/// <summary>
/// Syncs each workspace of <paramref name="accountWorkspaces"/> 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 <paramref name="ct"/>
/// stops the queue after the in-flight account rather than canceling its sync.
/// </summary>
public async Task<SyncAllResult> RunAsync(
IReadOnlyList<WorkspaceViewModel> 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);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="gclo.AccountWizardDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:gclo"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="{x:Bind ViewModel.Title}"
PrimaryButtonText="{x:Bind PrimaryButtonLabel(ViewModel.Step), Mode=OneWay}"
SecondaryButtonText="Back"
CloseButtonText="Cancel"
DefaultButton="Primary"
IsPrimaryButtonEnabled="{x:Bind PrimaryEnabled(ViewModel.IsValidatingToken), Mode=OneWay}"
IsSecondaryButtonEnabled="{x:Bind BackEnabled(ViewModel.Step), Mode=OneWay}"
PrimaryButtonClick="OnPrimaryButtonClick"
SecondaryButtonClick="OnSecondaryButtonClick">
<!-- The four step panels overlay each other; StepVisible shows exactly one. -->
<Grid MinWidth="400">
<!-- ============================================================ Step 1
Identity: the unique display name plus an optional note. -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(1, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 1 of 4: Identity" />
<TextBox
Header="Account name"
PlaceholderText="e.g. Work – Acme"
Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
x:Name="NameErrorText"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.NameError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.NameError), Mode=OneWay}" />
<TextBox
Header="Description (optional)"
PlaceholderText="What this account is for"
Text="{x:Bind ViewModel.Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<!-- ============================================================ Step 2
Access token: validated against GitHub when Next is pressed. -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(2, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 2 of 4: Access token" />
<PasswordBox
x:Name="TokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Next validates the token and lists the organizations it can see." />
<StackPanel
Orientation="Horizontal"
Spacing="8"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsValidatingToken), Mode=OneWay}">
<ProgressRing
Width="20"
Height="20"
VerticalAlignment="Center"
AutomationProperties.Name="Validating token"
IsActive="{x:Bind ViewModel.IsValidatingToken, Mode=OneWay}" />
<TextBlock VerticalAlignment="Center" Text="Validating token…" />
</StackPanel>
<TextBlock
x:Name="TokenErrorText"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.TokenError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TokenError), Mode=OneWay}" />
</StackPanel>
<!-- ============================================================ Step 3
Organization: pick from what the token can see, or type any name. -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(3, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 3 of 4: Organization" />
<ComboBox
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Choose from the list, or type a name"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
</StackPanel>
<!-- ============================================================ Step 4
Destination: where clones land and how many run in parallel. -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(4, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 4 of 4: Destination" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<NumberBox
x:Name="ConcurrencyBox"
Header="Parallel clones and pulls"
Minimum="1"
Maximum="64"
SmallChange="1"
SpinButtonPlacementMode="Inline"
ValueChanged="ConcurrencyBox_ValueChanged" />
<InfoBar
x:Name="SaveErrorBar"
Severity="Error"
IsClosable="True"
Title="Could not save the account" />
</StackPanel>
</Grid>
</ContentDialog>
+208
View File
@@ -0,0 +1,208 @@
using System;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>
/// Four-step account wizard (identity, token, organization, destination) over an
/// <see cref="AccountWizardViewModel"/>. Next/Save and Back are the dialog's primary
/// and secondary buttons; both clicks are intercepted (deferral + <c>args.Cancel</c>)
/// so the dialog only closes on Cancel or after the final step saved successfully —
/// <see cref="Saved"/> tells the caller which of the two happened.
///
/// As with every ContentDialog, the caller must set <c>XamlRoot</c> before
/// <c>ShowAsync</c>.
/// </summary>
public sealed partial class AccountWizardDialog : ContentDialog
{
// A ContentDialog has no HWND of its own; the host window supplies one for pickers.
private readonly Func<nint> _windowHandleProvider;
/// <summary>Guards Next/Back against double activation while a step handler runs.</summary>
private bool _interactionInFlight;
/// <summary>The wizard state this dialog renders; target of every x:Bind.</summary>
public AccountWizardViewModel ViewModel { get; }
/// <summary>True once the account was saved; false when the dialog was dismissed.</summary>
public bool Saved { get; private set; }
/// <summary>
/// Wires the dialog to its (caller-owned) view model. The
/// <paramref name="windowHandleProvider"/> returns the host window's HWND,
/// needed to initialize the folder picker on the destination step.
/// </summary>
public AccountWizardDialog(AccountWizardViewModel viewModel, Func<nint> 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
/// <summary>Shows a step panel only while it is the wizard's current step.</summary>
public Visibility StepVisible(int step, int current)
=> step == current ? Visibility.Visible : Visibility.Collapsed;
/// <summary>Primary button caption: "Save" on the last step, "Next" before it.</summary>
public string PrimaryButtonLabel(int step) => step == 4 ? "Save" : "Next";
/// <summary>Back is available everywhere but the first step.</summary>
public bool BackEnabled(int step) => step > 1;
/// <summary>Next is locked while the token step is talking to GitHub.</summary>
public bool PrimaryEnabled(bool isValidatingToken) => !isValidatingToken;
/// <summary>
/// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
/// </summary>
public string OrgSubfolderLabel(string organization)
=> string.IsNullOrWhiteSpace(organization)
? "Create org subfolder"
: $"Create {organization.Trim()} subfolder";
// ---------------------------------------------------------------- buttons
/// <summary>
/// Next/Save: advances one validated step, or saves and closes on the last one.
/// The dialog stays open (<c>args.Cancel</c>) in every case except a successful save.
/// </summary>
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();
}
}
/// <summary>Back: never closes the dialog; steps the wizard back one page.</summary>
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;
/// <summary>Mirrors the NumberBox's double into the view model's int.</summary>
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
/// <summary>
/// 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.
/// </summary>
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);
});
}
}
}
+9
View File
@@ -60,6 +60,15 @@
<NavigationViewItemSeparator />
</NavigationView.MenuItems>
<NavigationView.FooterMenuItems>
<NavigationViewItem
x:Name="SyncAllNavItem"
Content="Sync all"
Tag="syncall"
IsEnabled="False">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE895;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="Add account" Tag="add">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE710;" />
+400 -63
View File
@@ -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
/// <see cref="WorkspacePage"/> and its <see cref="WorkspaceViewModel"/>) 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 <see cref="SyncAllCoordinator"/>) and 'Add account' (the
/// <see cref="AccountWizardDialog"/>); account items get an Edit/Delete context menu.
/// </summary>
public sealed partial class MainWindow : Window
{
@@ -47,12 +51,19 @@ namespace gclo
/// <summary>Navigation items by workspace id, for badge and tooltip refreshes.</summary>
private readonly Dictionary<Guid, NavigationViewItem> _navItems = new();
/// <summary>The workspace the pane currently rests on; where 'Add account' reverts to.</summary>
/// <summary>The workspace the pane currently rests on; where the footer commands revert to.</summary>
private Guid _currentWorkspaceId = Guid.Empty;
/// <summary>Suppresses re-entrant SelectionChanged while the code puts a selection back.</summary>
private bool _revertingSelection;
/// <summary>
/// Cancels the in-flight 'Sync all' queue; null while none runs. The token source
/// itself stays local to <see cref="ToggleSyncAllAsync"/> so its lifetime is
/// contained there — only the ability to cancel escapes.
/// </summary>
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
}
}
/// <summary>
/// Puts the selection back on the current workspace after a footer command
/// (a command, not a destination) was activated.
/// </summary>
private void RevertSelectionToCurrent()
{
_revertingSelection = true;
try
{
WorkspaceNav.SelectedItem =
_navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current)
? current
: QuickSyncNavItem;
}
finally
{
_revertingSelection = false;
}
}
/// <summary>
/// Puts the workspace for <paramref name="id"/> on screen, creating and caching
/// it on first visit. Cached view models keep running syncs alive across switches.
/// </summary>
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;
/// <summary>
/// Returns the cached workspace for <paramref name="id"/>, 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>The Edit/Delete context menu for an account's pane item.</summary>
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;
}
/// <summary>
/// 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 <see cref="AccountsStore.GetAll"/>.
/// Only Guid-tagged items other than Quick Sync are accounts; everything before
/// them (Quick Sync, the separator) is skipped by the tag check.
/// </summary>
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);
}
/// <summary>
/// Runs the account wizard: adding when <paramref name="existing"/> is null,
/// editing (seeded from the account and its vault token) otherwise. On save,
/// the pane is updated accordingly; on cancel nothing changes.
/// </summary>
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);
}
/// <summary>
/// After the wizard saved a new account: create its pane item (sorted into the
/// account section) and select it, which shows its workspace.
/// </summary>
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
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// The 'Sync all' footer command: runs every account workspace sequentially in
/// pane order via <see cref="SyncAllCoordinator"/>, 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).
/// </summary>
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<WorkspaceViewModel>();
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();
}
}
/// <summary>
/// '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.
/// </summary>
private void UpdateSyncAllEnabled()
=> SyncAllNavItem.IsEnabled =
_cancelSyncAll is not null || _navItems.Keys.Any(id => id != Guid.Empty);
/// <summary>
/// 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.
/// </summary>
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
{