Phase 4: account wizard, edit/delete, Sync All #28
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 '<org>-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-<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.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,15 @@
|
||||
<NavigationViewItemSeparator />
|
||||
</NavigationView.MenuItems>
|
||||
<NavigationView.FooterMenuItems>
|
||||
<NavigationViewItem
|
||||
x:Name="SyncAllNavItem"
|
||||
Content="Sync all"
|
||||
Tag="syncall"
|
||||
IsEnabled="False">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="Add account" Tag="add">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
|
||||
+400
-63
@@ -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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user