fix(app): second review of #185: every account is counted once in the Sync all result, and Retry failed does not touch the selection

- The result sentence says how many of how many accounts were synced, then
  each group that was not, once: an account that could not be synced needs
  attention and is no longer 'skipped' as well (SyncAllResult.CouldNotSync,
  Total).
- Retry failed runs the failed rows without changing the selection at all, so
  there is nothing to give back and a change made while it runs stays.
- The account's page says what to do in the window's own words (no Credential
  Manager entry name), also for an account on the default token; the line is
  announced when it changes.

Refs #182

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-11 00:19:49 -06:00
1 parent 117e8959ca
commit d75bd4bd87
10 files changed
+218 -84

No files matched your search

+2 -2
View File
@@ -110,7 +110,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
<img src="docs/images/gclo-results.png" width="820" alt="Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59" />
</p>
8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows, and your selection is afterwards what it was), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running.
8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows and leaves your selection alone), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running.
**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. What it completes is its own. On Windows, a file that is already where the repository has one is replaced only when it is what the unfinished checkout wrote there (its content, or the beginning of it, line endings aside); for anything else the row fails and names the file: move it and sync again. The same check guards a repository that was empty when it was cloned and gets its first files later. Such a clone follows the origin's default branch when nothing has been put into its folder (otherwise it keeps the branch its HEAD names, and stays empty until origin has that branch); a clone that has branches of its own is left as it is when its HEAD names a branch without commits (a branch you started there yourself). If you have worked in an unfinished clone with git in the meantime, gclo finishes it only when every file there is still the repository's own and nothing you added with `git add` would be removed; otherwise the row fails and names the file. While a canceled clone or update is still ending, syncing that repository again fails with a message to try again when it has finished. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo/<org>/<repo>` inside WSL (reachable from Explorer as `\wsl.localhost\<distro>\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`).
@@ -124,7 +124,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
### Saved accounts
Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…**, on the account's page or in the pane's menu; the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
<p align="center">
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name and optional description" />
+109 -21
View File
@@ -188,7 +188,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false, Failed: 1), result);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1), result);
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Failed], states); // not a success with one skipped (#182)
Assert.Empty(_lister.Organizations); // never asked GitHub: stopped before loading
}
@@ -308,7 +308,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false, Failed: 1), result);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1), result);
Assert.False(alpha.HasLoadedRepos);
Assert.Equal(new[] { "org-beta-repo" }, git.ClonedRepoNames);
}
@@ -503,7 +503,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false, Failed: 1), result);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1), result);
Assert.Empty(_lister.Organizations); // GitHub was never asked with the old token
}
@@ -522,7 +522,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync([workspace], CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false, Failed: 1), result);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1), result);
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Failed], states);
}
@@ -541,8 +541,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync([workspace], CancellationToken.None);
Assert.Equal(RunResultKind.Error, workspace.ResultKind);
Assert.Equal(0, result.Ran);
Assert.Equal(1, result.Skipped);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1), result);
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Running, SyncAllAccountState.Failed], states);
}
@@ -607,7 +606,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
Assert.True(status.IsOpen); // the result stays until it is closed
Assert.Equal(SyncAllOutcome.Finished, status.Outcome);
Assert.Equal("Sync all finished", status.Title);
Assert.Equal("2 accounts synced, 0 skipped.", status.Message);
Assert.Equal("2 of 2 accounts synced.", status.Message);
Assert.Null(status.CurrentAccountId);
Assert.Null(status.Fraction);
@@ -627,14 +626,14 @@ public sealed class SyncAllCoordinatorTests : IDisposable
"beta: 0 of 1 repository.",
"beta: 1 of 1 repository.",
"beta: starting.",
"2 accounts synced, 0 skipped.",
"2 of 2 accounts synced.",
],
said);
// The pass is over: what the accounts do next is no longer the bar's business.
status.IsOpen = false;
await alpha.SyncCommand.ExecuteAsync(null);
Assert.Equal("2 accounts synced, 0 skipped.", status.Message);
Assert.Equal("2 of 2 accounts synced.", status.Message);
Assert.False(status.IsOpen);
}
@@ -666,7 +665,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
await run.WaitAsync(TimeSpan.FromSeconds(30));
Assert.Contains(0.5, fractions);
Assert.Equal("1 account synced, 0 skipped.", status.Message);
Assert.Equal("1 of 1 account synced.", status.Message);
}
@@ -698,7 +697,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
Assert.True(result.WasCanceled);
Assert.Equal(SyncAllOutcome.Canceled, status.Outcome);
Assert.Equal("Sync all canceled", status.Title);
Assert.Equal("1 account synced, 0 skipped. 1 account was not started.", status.Message);
Assert.Equal("1 of 2 accounts synced. 1 account was not started.", status.Message);
Assert.False(status.IsRunning);
// The next pass starts clean.
@@ -724,10 +723,10 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await _coordinator.RunAsync([failing, blocked, fine], CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 1, WasCanceled: false, Failed: 2), result);
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false, Failed: 2, CouldNotSync: 1), result);
Assert.Equal(SyncAllOutcome.FinishedWithFailures, status.Outcome);
Assert.Equal("Sync all finished", status.Title);
Assert.Equal("2 accounts synced, 1 skipped. 2 accounts need attention; they are marked in the pane.", status.Message);
Assert.Equal("2 of 3 accounts synced. 2 accounts need attention; they are marked in the pane.", status.Message);
}
@@ -751,10 +750,10 @@ public sealed class SyncAllCoordinatorTests : IDisposable
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true, Failed: 1, NotStarted: 1), result);
Assert.Equal(
"1 account synced, 0 skipped. 1 account needs attention; it is marked in the pane. 1 account was not started.",
"1 of 2 accounts synced. 1 account needs attention; it is marked in the pane. 1 account was not started.",
SyncAllStatus.Describe(result));
Assert.Equal(
"0 accounts synced, 0 skipped. 3 accounts were not started.",
"0 of 3 accounts synced. 3 accounts were not started.",
SyncAllStatus.Describe(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: true, NotStarted: 3)));
}
@@ -851,7 +850,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
await _coordinator.RunAsync([shared], CancellationToken.None);
Assert.Equal(SyncAllOutcome.FinishedWithFailures, status.Outcome);
Assert.Equal("0 accounts synced, 1 skipped. 1 account needs attention; it is marked in the pane.", status.Message);
Assert.Equal("0 of 1 account synced. 1 account needs attention; it is marked in the pane.", status.Message);
}
@@ -895,14 +894,18 @@ public sealed class SyncAllCoordinatorTests : IDisposable
SyncAllResult result = await run.WaitAsync(TimeSpan.FromSeconds(30));
Assert.Equal(RunResultKind.Canceled, alpha.ResultKind);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false, Failed: afterAFailure ? 1 : 0), result); // beta ran
Assert.Equal(
afterAFailure
? new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false, Failed: 1, CouldNotSync: 1)
: new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false),
result); // beta ran
Assert.Equal(
[SyncAllAccountState.Queued, SyncAllAccountState.Running, afterAFailure ? SyncAllAccountState.Failed : SyncAllAccountState.Skipped],
states);
Assert.Equal(
afterAFailure
? "1 account synced, 1 skipped. 1 account needs attention; it is marked in the pane."
: "1 account synced, 1 skipped.",
? "1 of 2 accounts synced. 1 account needs attention; it is marked in the pane."
: "1 of 2 accounts synced. 1 account was skipped (busy, canceled, or nothing selected).",
status.Message);
}
@@ -911,7 +914,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
[Theory]
[InlineData(true)]
[InlineData(false)] // one repository was not selected: it is not selected afterwards either
public async Task RetryFailed_GivesTheSelectionBack_SoTheNextSyncCoversWhatItCoveredBefore(bool allSelected)
public async Task RetryFailed_LeavesTheSelectionAlone_SoTheNextSyncCoversWhatItCoveredBefore(bool allSelected)
{
_lister.Handler = _ => [Repo("one"), Repo("two"), Repo("three")];
bool failTwo = true;
@@ -935,7 +938,7 @@ public sealed class SyncAllCoordinatorTests : IDisposable
await alpha.RetryFailedCommand.ExecuteAsync(null);
Assert.False(alpha.HasFailedRepos);
Assert.Equal(selected, alpha.SelectedCount); // the retry ran one repository; the selection is the user's again
Assert.Equal(selected, alpha.SelectedCount); // the retry ran one repository; the selection is still the user's
Assert.Equal(selected, alpha.Repos.Count(repo => repo.IsSelected));
Assert.Equal(allSelected, alpha.Repos.Single(repo => string.Equals(repo.Name, "three", StringComparison.Ordinal)).IsSelected);
@@ -960,7 +963,45 @@ public sealed class SyncAllCoordinatorTests : IDisposable
shared.RefreshAccountToken();
Assert.Null(shared.MissingTokenDescription);
Assert.Null(shared.MissingTokenAdvice);
Assert.Contains(changed, name => string.Equals(name, nameof(WorkspaceViewModel.MissingTokenDescription), StringComparison.Ordinal));
Assert.Contains(changed, name => string.Equals(name, nameof(WorkspaceViewModel.MissingTokenAdvice), StringComparison.Ordinal));
}
[Fact]
public void AnAccountsPage_SaysWhereItsTokenIsSaved_InTheWordsOfTheWindow()
{
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: false);
WorkspaceViewModel own = CreateWorkspace("own");
_vault.Delete(own.AccountId!.Value);
own.RefreshAccountToken();
Assert.Equal(
"No default token is saved. Save one in File > Settings, or give this account its own token with Edit account…",
shared.MissingTokenAdvice);
Assert.Equal("No token is saved for this account. Choose Edit account… and enter one.", own.MissingTokenAdvice);
Assert.Null(CreateWorkspace("fine").MissingTokenAdvice);
}
[Fact]
public async Task SeveralAccountsThatWereSkipped_AreCountedOnce_AndNotAsNeedingAttention()
{
WorkspaceViewModel first = CreateWorkspace("first");
WorkspaceViewModel second = CreateWorkspace("second");
_lister.Handler = _ => []; // nothing to sync in either
var status = new SyncAllStatus();
_coordinator.Status = status;
SyncAllResult result = await _coordinator.RunAsync([first, second], CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 2, WasCanceled: false), result);
Assert.Equal(2, result.Total);
Assert.Equal(SyncAllOutcome.Finished, status.Outcome);
Assert.Equal("0 of 2 accounts synced. 2 accounts were skipped (busy, canceled, or nothing selected).", status.Message);
}
@@ -980,4 +1021,51 @@ public sealed class SyncAllCoordinatorTests : IDisposable
gate.SetResult();
await sync.WaitAsync(TimeSpan.FromSeconds(30));
}
[Fact]
public async Task RetryFailed_RunsTheFailedRows_AlsoWhenTheyAreNotSelected_AndASelectionChangeMadeMeanwhileStays()
{
_lister.Handler = _ => [Repo("one"), Repo("two"), Repo("three")];
bool failTwo = true;
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var git = new FakeGitClient
{
CloneHandler = (_, path, _, _, _) =>
{
if(!path.EndsWith("two", StringComparison.Ordinal))
{
return Task.CompletedTask;
}
if(failTwo)
{
return Task.FromException(new InvalidOperationException("boom"));
}
entered.TrySetResult();
return gate.Task;
},
};
WorkspaceViewModel alpha = CreateWorkspace("alpha", git);
await alpha.LoadReposCommand.ExecuteAsync(null);
await alpha.SyncCommand.ExecuteAsync(null);
RepoItemViewModel Row(string name) => alpha.Repos.Single(repo => string.Equals(repo.Name, name, StringComparison.Ordinal));
Row("two").IsSelected = false; // the failed row is taken out of the selection: the retry is for it all the same
int before = git.CloneCalls.Count;
failTwo = false;
Task retry = alpha.RetryFailedCommand.ExecuteAsync(null);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(30));
Row("one").IsSelected = false; // the checkboxes stay enabled while a run is going on
gate.SetResult();
await retry.WaitAsync(TimeSpan.FromSeconds(30));
Assert.Equal(SyncStatus.Done, Row("two").Status);
Assert.Equal(1, git.CloneCalls.Count - before); // the failed row, and only it
Assert.False(Row("one").IsSelected);
Assert.False(Row("two").IsSelected);
Assert.True(Row("three").IsSelected);
Assert.Equal(1, alpha.SelectedCount);
}
}
+1 -1
View File
@@ -641,7 +641,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
Assert.Equal(SyncStatus.Done, byName["bravo"].Status);
Assert.Null(byName["bravo"].Error);
Assert.Equal(SyncStatus.Done, byName["alpha"].Status); // untouched by the retry
Assert.True(byName["bravo"].IsSelected); // the retry ran exactly the failure, and the selection is what it was (#182)
Assert.True(byName["bravo"].IsSelected); // the retry ran exactly the failure, and the selection was not touched (#182)
Assert.True(byName["alpha"].IsSelected);
Assert.True(byName["charlie"].IsSelected);
Assert.Equal(1, vm.TotalCount); // run-scoped (#22): the retry run was just bravo
+15 -10
View File
@@ -42,10 +42,10 @@ public sealed class SyncAllCoordinator(IActivityLog log)
/// loading its repositories first when needed. A workspace is skipped (counted,
/// logged, never fatal) when it is already busy or has nothing to sync. One that
/// cannot be synced (it has no token, its repositories cannot be loaded, its sync
/// cannot start) is skipped too, and counted and announced as failed: the pass
/// must not read as a success when an account was not synced (#182). Canceling
/// <paramref name="ct"/> stops the queue after the in-flight account rather than
/// canceling its sync.
/// cannot start) is left out too, and counted and announced as failed, not as
/// skipped: the pass must not read as a success when an account was not synced
/// (#182). 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)
@@ -83,6 +83,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
int ran = 0;
int skipped = 0;
int failed = 0;
int couldNotSync = 0;
for(int i = 0; i < accountWorkspaces.Count; i++)
{
@@ -98,7 +99,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
Announce(accountWorkspaces[rest], SyncAllAccountState.Skipped);
}
_log.Info("Sync all: canceled — remaining accounts were not started.");
return new SyncAllResult(ran, skipped, WasCanceled: true, failed, NotStarted: accountWorkspaces.Count - i);
return new SyncAllResult(ran, skipped, WasCanceled: true, failed, NotStarted: accountWorkspaces.Count - i, couldNotSync);
}
if(workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths)
@@ -116,7 +117,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
// Whichever source the account's token comes from, it holds none: say
// which, instead of a sync that cannot start or a generic auth failure
// that sends the user to the wrong place.
skipped++;
couldNotSync++;
failed++;
Announce(workspace, SyncAllAccountState.Failed);
_log.Info($"Sync all: '{workspace.DisplayName}' could not be synced — {missingToken}");
@@ -132,7 +133,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
await workspace.LoadReposCommand.ExecuteAsync(null);
if(!workspace.HasLoadedRepos)
{
skipped++;
couldNotSync++;
failed++;
Announce(workspace, SyncAllAccountState.Failed);
_log.Info(
@@ -156,7 +157,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
// The sync never ran its repositories (its target folder could not be
// used, its token went away at that moment): that is not a success with
// nothing to report.
skipped++;
couldNotSync++;
failed++;
Announce(workspace, SyncAllAccountState.Failed);
_log.Info($"Sync all: '{workspace.DisplayName}' could not be synced — {workspace.ResultMessage}");
@@ -170,11 +171,15 @@ public sealed class SyncAllCoordinator(IActivityLog log)
// was edited meanwhile) and left repositories unsynced: that is not a //
// synced account. The pass itself goes on. //
///////////////////////////////////////////////////////////////////////////
skipped++;
if(workspace.HasFailedRepos)
{
couldNotSync++;
failed++;
}
else
{
skipped++;
}
Announce(workspace, workspace.HasFailedRepos ? SyncAllAccountState.Failed : SyncAllAccountState.Skipped);
_log.Info($"Sync all: '{workspace.DisplayName}' was canceled before it finished.");
continue;
@@ -191,7 +196,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
_log.Info($"Sync all: finished '{workspace.DisplayName}'.");
}
return new SyncAllResult(ran, skipped, WasCanceled: false, failed);
return new SyncAllResult(ran, skipped, WasCanceled: false, failed, CouldNotSync: couldNotSync);
}
+18 -6
View File
@@ -11,14 +11,26 @@ namespace gclo.ViewModels;
/// </summary>
/// <param name="Ran">Accounts whose sync ran to its end (with or without failed repositories).</param>
/// <param name="Skipped">
/// Accounts whose turn came and whose sync did not run to its end: they were busy,
/// had nothing to sync, could not be synced (no token, repositories that could not
/// be loaded, a sync that could not start), or their own sync was canceled.
/// Accounts whose turn came and that were left out with nothing wrong: they were
/// busy, had nothing to sync, or their own sync was canceled.
/// </param>
/// <param name="WasCanceled">True when cancellation stopped the queue before every account was processed.</param>
/// <param name="Failed">
/// Accounts that ended the pass marked as failed: their sync ran and a repository
/// failed, or they could not be synced. The second kind is also in <paramref name="Skipped"/>.
/// Accounts that need attention: their sync ran and a repository failed, or they
/// could not be synced. The second kind is counted in <paramref name="CouldNotSync"/> too.
/// </param>
/// <param name="NotStarted">Accounts whose turn never came, because the pass was canceled before it.</param>
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled, int Failed = 0, int NotStarted = 0);
/// <param name="CouldNotSync">
/// Accounts whose turn came and that could not be synced: no token, repositories
/// that could not be loaded, a sync that could not start, or their own sync canceled
/// after a repository had failed.
/// </param>
/// <remarks>
/// <paramref name="Ran"/>, <paramref name="Skipped"/>, <paramref name="CouldNotSync"/>
/// and <paramref name="NotStarted"/> count every account of the pass exactly once.
/// </remarks>
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled, int Failed = 0, int NotStarted = 0, int CouldNotSync = 0)
{
/// <summary>The accounts of the pass.</summary>
public int Total => Ran + Skipped + CouldNotSync + NotStarted;
}
+12 -1
View File
@@ -158,13 +158,24 @@ public sealed partial class SyncAllStatus : ObservableObject
/// <summary>The sentence that sums up a finished or canceled pass.</summary>
internal static string Describe(SyncAllResult result)
{
string summary = $"{Count(result.Ran, "account")} synced, {result.Skipped.ToString(CultureInfo.InvariantCulture)} skipped.";
///////////////////////////////////////////////////////////////////////////////
// How many of how many, then each group that was not synced once: an //
// account that could not be synced is one that needs attention, not also //
// one that was "skipped" (#182). //
///////////////////////////////////////////////////////////////////////////////
string summary = $"{result.Ran.ToString(CultureInfo.InvariantCulture)} of {Count(result.Total, "account")} synced.";
summary += result.Failed switch
{
0 => "",
1 => " 1 account needs attention; it is marked in the pane.",
_ => $" {result.Failed.ToString(CultureInfo.InvariantCulture)} accounts need attention; they are marked in the pane.",
};
summary += result.Skipped switch
{
0 => "",
1 => " 1 account was skipped (busy, canceled, or nothing selected).",
_ => $" {result.Skipped.ToString(CultureInfo.InvariantCulture)} accounts were skipped (busy, canceled, or nothing selected).",
};
return summary + result.NotStarted switch
{
0 => "",
+28 -29
View File
@@ -221,6 +221,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
[NotifyPropertyChangedFor(nameof(MissingTokenDescription))]
[NotifyPropertyChangedFor(nameof(MissingTokenAdvice))]
public partial string Token { get; set; }
[ObservableProperty]
@@ -605,6 +606,20 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>
/// What an account's page says when the account has no token to work with: where
/// to save one, in the words of the window's own menus. Null when it has one, and
/// for Quick Sync, whose token is typed into its box.
/// </summary>
public string? MissingTokenAdvice
=> _account is null || Token.Trim().Length > 0
? null
: AccountAsItIsNow().UsesDefaultToken
? "No default token is saved. Save one in File > Settings, or give this account its own token with Edit account…"
: "No token is saved for this account. Choose Edit account… and enter one.";
/// <summary>
/// Tells the workspace that the default token in Settings was saved, replaced, or
/// removed. An account workspace takes its token from its source again. Quick
@@ -1017,7 +1032,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// token leaves the table (failures, their paths, the retry) as it was.
string token = TokenToUseNow();
var selected = Repos.Where(r => r.IsSelected).ToList();
List<RepoItemViewModel> selected = _runOnly ?? [.. Repos.Where(r => r.IsSelected)];
foreach(RepoItemViewModel item in selected)
{
item.Status = SyncStatus.Queued;
@@ -1131,17 +1146,16 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>
/// Selects exactly the failed repositories, runs the same sync path again, and
/// gives the selection back as it was. The retry is one run, not a change of what
/// the next sync covers: left narrowed, a later Sync (or Sync all) would sync only
/// the rows that had failed and report the account as synced (#182).
/// Runs the same sync path again for exactly the failed repositories. The
/// selection is not touched: the retry is one run, not a change of what the next
/// sync covers. (It used to select the failed rows; a later Sync, or Sync all,
/// then synced only those and reported the account as synced, #182.)
/// </summary>
[RelayCommand(CanExecute = nameof(CanRetryFailed))]
private async Task RetryFailedAsync()
{
_log.Info($"Retrying {Repos.Count(r => r.Status == SyncStatus.Failed)} failed repositories.");
HashSet<RepoItemViewModel> selectedBefore = [.. Repos.Where(item => item.IsSelected)];
SelectOnly(item => item.Status == SyncStatus.Failed);
_runOnly = [.. Repos.Where(r => r.Status == SyncStatus.Failed)];
_log.Info($"Retrying {_runOnly.Count} failed repositories.");
try
{
// Executing SyncCommand itself keeps SyncCancelCommand working for retry runs.
@@ -1149,32 +1163,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
finally
{
SelectOnly(selectedBefore.Contains);
_runOnly = null;
}
}
/// <summary>Selects the repositories <paramref name="wanted"/> picks and no others, as one change.</summary>
private void SelectOnly(Func<RepoItemViewModel, bool> wanted)
{
// One batch: per-row recounts and command re-queries would be O(n^2) (#30).
_syncingSelection = true;
try
{
foreach(RepoItemViewModel item in Repos)
{
item.IsSelected = wanted(item);
}
}
finally
{
_syncingSelection = false;
}
RecomputeSelectedCount();
UpdateAllSelectedFromItems();
SyncCommand.NotifyCanExecuteChanged();
}
/// <summary>
/// The repositories the run that is starting takes instead of the selected ones;
/// null for an ordinary sync. Set by <see cref="RetryFailedAsync"/> for its one run.
/// </summary>
private List<RepoItemViewModel>? _runOnly;
+6 -2
View File
@@ -96,13 +96,17 @@ public sealed partial class ConnectionFormControl : UserControl
// (#182). //
///////////////////////////////////////////////////////////////////////////////
TokenBox.IsEnabled = !savedWithAnAccount;
TokenBox.PlaceholderText = savedWithAnAccount ? "No token is saved for this account" : "ghp_…";
TokenBox.PlaceholderText = !savedWithAnAccount ? "ghp_…"
: ViewModel!.UsesDefaultToken ? "No default token is saved"
: "No token is saved for this account";
// The organization placeholder must not ask for a token the box already has, or cannot take.
OrgBox.PlaceholderText = hasToken ? "Choose an organization, or type one"
: savedWithAnAccount ? "Type an organization or account"
: "Paste a token first, or type an org";
ToolTipService.SetToolTip(TokenBox, savedWithAnAccount
? "This account's token is set with Edit account…, not typed here."
? ViewModel!.UsesDefaultToken
? "This account uses the default token from File > Settings."
: "This account's token is set with Edit account…, not typed here."
: !hasToken
? "Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
: "Using the saved default token from Settings. Type here to use a different one for this session.");
+4 -3
View File
@@ -135,15 +135,16 @@
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="This account's token is set with Edit account…, not typed here."
Text="{x:Bind AccountTokenNote, Mode=OneTime}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsAccountWorkspace), Mode=OneTime}" />
<TextBlock
x:Name="MissingTokenTextBlock"
AutomationProperties.AutomationId="ConnectMissingTokenText"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.MissingTokenDescription, Mode=OneWay}"
Visibility="{x:Bind MissingTokenVisibility(ViewModel.MissingTokenDescription), Mode=OneWay}" />
Text="{x:Bind ViewModel.MissingTokenAdvice, Mode=OneWay}"
Visibility="{x:Bind MissingTokenVisibility(ViewModel.MissingTokenAdvice), Mode=OneWay}" />
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
+23 -9
View File
@@ -113,12 +113,17 @@ public sealed partial class WorkspacePage : UserControl
/// <summary>
/// Whether the connect card says why the account has no token to work with. Quick
/// Sync's box is where its token is typed, so an empty one needs no explanation.
/// </summary>
public Visibility MissingTokenVisibility(string? missing)
=> IsAccountWorkspace && !string.IsNullOrEmpty(missing) ? Visibility.Visible : Visibility.Collapsed;
/// <summary>Where an account's token is set, said on its connect card: the box there takes none.</summary>
public string AccountTokenNote
=> ViewModel.UsesDefaultToken
? "This account uses the default token from File > Settings."
: "This account's token is set with Edit account…, not typed here.";
/// <summary>Whether the connect card says that the account has no token, and where to save one.</summary>
public Visibility MissingTokenVisibility(string? advice)
=> string.IsNullOrEmpty(advice) ? Visibility.Collapsed : Visibility.Visible;
@@ -225,6 +230,11 @@ public sealed partial class WorkspacePage : UserControl
// it here. Enqueued so the binding has pushed the new text first.
DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
}
else if(string.Equals(e.PropertyName, nameof(WorkspaceViewModel.MissingTokenAdvice), StringComparison.Ordinal))
{
// The same goes for the connect card's line about a missing token.
DispatcherQueue.TryEnqueue(() => RaiseLiveRegionChanged(MissingTokenTextBlock));
}
else if(e.PropertyName is nameof(WorkspaceViewModel.SelectedCount)
or nameof(WorkspaceViewModel.LoadedOrganization))
{
@@ -454,10 +464,14 @@ public sealed partial class WorkspacePage : UserControl
private void RaiseStatusLiveRegionChanged()
private void RaiseStatusLiveRegionChanged() => RaiseLiveRegionChanged(StatusTextBlock);
private static void RaiseLiveRegionChanged(FrameworkElement element)
{
AutomationPeer peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock)
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock);
AutomationPeer peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(element)
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(element);
peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
}