fix(app): review of #169: the retry action keeps its guard, the order is protected by tests, and smaller repairs

- The result bar's action is disabled, and does nothing, while a retry cannot
  run (a path recovery, a load, a sync): Execute alone does not ask.
- TableOrderTests checks the table against the rule written out in the test,
  step by step and over random runs; it fails for an order regression that the
  earlier tests let through.
- A row from before a reload is not put back into the table.
- A batch hands over every report even when the handler fails for one.
- The monitor names what was running at the time of a stall, and does not take
  a process that was not running (sleep, a debugger) for a window that did not
  respond.
- The status never shows more done than there is to do at the start of a run.
- The folder button says so when the folder does not exist yet.
- The UI test for the header cycle follows the third click; stale comments;
  the live region is announced once the bar is on screen.

Refs #167
Refs #168

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-10 19:51:00 -06:00
1 parent f426a2f7b5
commit ae81e0097b
15 files changed
+692 -56

No files matched your search

+1 -1
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), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer at any time.
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), 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. 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`).
@@ -180,6 +180,33 @@ public sealed class BatchedRepoProgressTests
[Fact]
public void AHandlerThatFailsForOneReport_StillGetsTheOthers_AndTheFailureIsNotSwallowed()
{
var delivered = new List<string>();
using var progress = new BatchedRepoProgress(
report =>
{
if(string.Equals(report.RepoName, "alpha", StringComparison.Ordinal))
{
throw new InvalidOperationException("the row for alpha is gone");
}
delivered.Add(report.RepoName);
},
_window, s_interval, _time);
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
progress.Report(new RepoProgress("bravo", SyncStatus.Done));
progress.Report(new RepoProgress("charlie", SyncStatus.Failed, Error: "boom"));
Timer.Fire();
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() => _window.RunAll());
Assert.Equal("the row for alpha is gone", failure.Message);
Assert.Equal(["bravo", "charlie"], delivered, StringComparer.Ordinal); // their final states were not lost
}
[Fact]
public void AMissingHandlerOrReport_IsRefused()
{
+64 -10
View File
@@ -51,12 +51,27 @@ public sealed class ResponsivenessMonitorTests
/// <summary>The window answers at once, and nothing happens until the quiet period after a report is over.</summary>
/// <summary>The monitor looks every 100 ms, as its timer would, for the given time; nothing answers.</summary>
private void Looks(ResponsivenessMonitor monitor, TimeSpan during)
{
for(TimeSpan passed = TimeSpan.Zero; passed < during; passed += ResponsivenessMonitor.DefaultInterval)
{
_time.Advance(ResponsivenessMonitor.DefaultInterval);
monitor.Check();
}
}
/// <summary>The window answers every question at once until the quiet period after a report is over.</summary>
private void QuietPeriodPasses(ResponsivenessMonitor monitor)
{
Answer(TimeSpan.Zero);
_time.Advance(ResponsivenessMonitor.QuietPeriod);
monitor.Check();
for(TimeSpan passed = TimeSpan.Zero; passed <= ResponsivenessMonitor.QuietPeriod; passed += ResponsivenessMonitor.DefaultInterval)
{
Answer(TimeSpan.Zero);
_time.Advance(ResponsivenessMonitor.DefaultInterval);
monitor.Check();
}
}
@@ -156,22 +171,61 @@ public sealed class ResponsivenessMonitorTests
using ResponsivenessMonitor monitor = Create();
monitor.Check(); // asks; no answer comes
_time.Advance(TimeSpan.FromSeconds(4));
monitor.Check();
Looks(monitor, TimeSpan.FromSeconds(4.9));
Assert.Empty(_log.Entries); // not long enough yet
_time.Advance(TimeSpan.FromSeconds(1));
monitor.Check();
monitor.Check(); // said once, not on every look
Looks(monitor, TimeSpan.FromSeconds(0.1));
Looks(monitor, TimeSpan.FromSeconds(1)); // said once, not on every look
Assert.Equal([("ERROR", "The window has not responded for 5000 ms and still does not.")], _log.Entries);
Assert.Single(_asked); // no second question while the first is unanswered
Stall(monitor, 2000);
Looks(monitor, TimeSpan.FromSeconds(1));
Stall(monitor, 0); // it answers, seven seconds after it was asked
Assert.Equal("The window did not respond for 7000 ms.", _log.Messages[^1]);
}
[Fact]
public void TheCollectedStalls_NameWhatWasRunningThen_NotWhatRunsWhenTheyAreReported()
{
using ResponsivenessMonitor monitor = Create();
monitor.Activity = "syncing 'Engineering'";
monitor.Check();
Stall(monitor, 400);
Stall(monitor, 300);
monitor.Activity = null; // the sync is over by the time the summary is written
QuietPeriodPasses(monitor);
Assert.Equal(
"Since then the window did not respond once more while syncing 'Engineering': 300 ms in total, the longest 300 ms.",
_log.Messages[^1]);
}
[Fact]
public void AProcessThatWasNotRunning_IsNotAWindowThatDidNotRespond()
{
using ResponsivenessMonitor monitor = Create();
monitor.Check(); // asks
// The machine sleeps for an hour: no look, no answer. Then both come back.
_time.Advance(TimeSpan.FromHours(1));
_asked.Dequeue()();
monitor.Check();
Assert.Empty(_log.Entries); // the question says nothing about the window
Assert.Single(_asked); // and the watch goes on with a new one
Stall(monitor, 400);
Assert.Equal(["The window did not respond for 400 ms."], _log.Messages);
Assert.Equal(TimeSpan.FromSeconds(2), ResponsivenessMonitor.PauseAfter);
}
[Fact]
public void WhenTheWindowTakesNoMoreWork_TheWatchEnds_AndWhatWasCollectedIsReported()
{
@@ -777,6 +777,39 @@ public sealed class SyncAllCoordinatorTests : IDisposable
[Fact]
public async Task ASecondPass_WithFewerRepositoriesSelected_NeverShowsMoreDoneThanThereIsToDo()
{
_lister.Handler = org => [Repo(org + "-one"), Repo(org + "-two"), Repo(org + "-three")];
WorkspaceViewModel alpha = CreateWorkspace("alpha");
var status = new SyncAllStatus();
_coordinator.Status = status;
await _coordinator.RunAsync([alpha], CancellationToken.None); // three of three
alpha.Repos[1].IsSelected = false;
alpha.Repos[2].IsSelected = false;
var fractions = new List<double?>();
var said = new List<string>();
status.PropertyChanged += (_, e) =>
{
if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Fraction), StringComparison.Ordinal))
{
fractions.Add(status.Fraction);
}
else if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Message), StringComparison.Ordinal))
{
said.Add(status.Message);
}
};
await _coordinator.RunAsync([alpha], CancellationToken.None); // one of one; the run before left 3 of 3 behind
Assert.All(fractions, fraction => Assert.True(fraction is null or (>= 0 and <= 1), $"fraction {fraction}"));
Assert.DoesNotContain(said, message => message.Contains("3 of 1", StringComparison.Ordinal));
Assert.Contains("alpha: 1 of 1 repository.", said, StringComparer.Ordinal);
}
[Fact]
public void ACancelRequest_BeforeTheFirstAccountHasStarted_IsRemembered()
{
+341
View File
@@ -0,0 +1,341 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// The repository table's default order (#168), checked against the rule as it is
/// written here, not against the view model's own comparison: while a sync runs, the
/// rows in flight (the one that started first on top), then the failed ones and the
/// finished ones (the latest first in each), then the ones waiting, by name; with no
/// sync running, the rows in flight, the failed ones, and then the rest, by name.
/// Every report is made on the test's thread through the view model's run seam, so
/// each step is checked the moment it happened.
/// </summary>
public sealed class TableOrderTests : IDisposable
{
private readonly FakeRepositoryLister _lister = new();
private readonly string _folder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly List<WorkspaceViewModel> _made = [];
/// <summary>What the test knows about each row: its status, and when it last started or finished.</summary>
private readonly Dictionary<string, (SyncStatus Status, long Activity)> _rows = new(StringComparer.Ordinal);
private TaskCompletionSource<SyncSummary>? _run;
private IProgress<RepoProgress>? _progress;
private List<string> _runNames = [];
private long _clock;
public void Dispose()
{
foreach(WorkspaceViewModel vm in _made)
{
vm.Dispose();
}
TryDeleteDirectory(_folder);
}
/// <summary>Hands each report to the view model at once, on the reporting (test) thread.</summary>
private sealed class Inline(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
/// <summary>A loaded workspace whose sync runs are driven by the test, report by report.</summary>
private async Task<WorkspaceViewModel> LoadAsync(params string[] names)
{
_lister.Repositories = [.. names.Select(n => Repo(n))];
var vm = new WorkspaceViewModel(
_lister, new FakeGitClient(), new FakeOrganizationLister(),
handler => new Inline(handler), TimeSpan.FromMilliseconds(1), new NullActivityLog(), wsl: new FakeWslCloner());
_made.Add(vm);
vm.Organization = "acme";
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = _folder;
vm.SyncRunner = (_, repos, progress, _) =>
{
_run = new TaskCompletionSource<SyncSummary>();
_progress = progress;
_runNames = [.. repos.Select(r => r.Name)];
foreach(string name in _runNames)
{
// The run takes its rows back to waiting; when they last finished stays as it was.
_rows[name] = (SyncStatus.Queued, _rows[name].Activity);
progress.Report(new RepoProgress(name, SyncStatus.Queued));
}
return _run.Task;
};
await vm.LoadReposCommand.ExecuteAsync(null);
foreach(string name in names)
{
_rows[name] = (SyncStatus.Queued, 0);
}
return vm;
}
private void Report(string name, SyncStatus status)
{
if(_rows[name].Status != status)
{
_rows[name] = (status, ++_clock);
}
_progress!.Report(new RepoProgress(name, status, status == SyncStatus.Failed ? "boom" : null));
}
/// <summary>Ends the run in flight; rows that never finished are reported canceled, as the engine does.</summary>
private void EndRun()
{
foreach(string name in _runNames)
{
if(_rows[name].Status is not (SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled))
{
Report(name, SyncStatus.Canceled);
}
}
TaskCompletionSource<SyncSummary> run = _run!;
_run = null;
run.SetResult(new SyncSummary(_runNames.Count, 0, 0, 0, 0, WasCanceled: false));
}
/// <summary>The run in flight stops on an error, with its rows left as they are.</summary>
private void FailRun()
{
TaskCompletionSource<SyncSummary> run = _run!;
_run = null;
run.SetException(new InvalidOperationException("the run failed"));
}
/// <summary>The rule, written out: which names the table shows, in which order.</summary>
private string[] Expected(RepoFilter filter = RepoFilter.All)
{
bool running = _run is not null;
int Group(SyncStatus status) => status switch
{
SyncStatus.Cloning or SyncStatus.Pulling => 0,
SyncStatus.Failed => 1,
SyncStatus.Done or SyncStatus.Canceled when running => 2,
_ => 3,
};
long WithinGroup((SyncStatus Status, long Activity) row) => Group(row.Status) switch
{
0 => row.Activity, // the one that started first stays on top
1 or 2 when running => -row.Activity, // the latest first
_ => 0, // by name
};
return
[
.. _rows
.Where(row => filter switch
{
RepoFilter.Active => row.Value.Status is SyncStatus.Cloning or SyncStatus.Pulling,
RepoFilter.Failed => row.Value.Status == SyncStatus.Failed,
RepoFilter.Pending => row.Value.Status == SyncStatus.Queued,
_ => true,
})
.OrderBy(row => Group(row.Value.Status))
.ThenBy(row => WithinGroup(row.Value))
.ThenBy(row => row.Key, StringComparer.OrdinalIgnoreCase)
.Select(row => row.Key),
];
}
private static string[] Table(WorkspaceViewModel vm) => [.. vm.FilteredRepos.Select(r => r.Name)];
[Fact]
public async Task SeveralInFlight_OneFinishing_OneFailing_OneCanceled_EachRowIsWhereTheRulePutsIt()
{
WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie", "delta", "echo");
Task sync = vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm));
Report("bravo", SyncStatus.Cloning);
Report("alpha", SyncStatus.Pulling);
Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], Table(vm)); // bravo started first: it stays on top
Report("bravo", SyncStatus.Done); // moves down, below the one still in flight
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm));
Report("echo", SyncStatus.Cloning); // from the last place to the end of the rows in flight
Assert.Equal(["alpha", "echo", "bravo", "charlie", "delta"], Table(vm));
Report("alpha", SyncStatus.Failed); // failed: below the rows in flight, above the finished
Assert.Equal(["echo", "alpha", "bravo", "charlie", "delta"], Table(vm));
Report("echo", SyncStatus.Canceled); // finished after bravo: above it
Assert.Equal(["alpha", "echo", "bravo", "charlie", "delta"], Table(vm));
Report("delta", SyncStatus.Cloning);
Report("delta", SyncStatus.Done); // the latest of the finished
Assert.Equal(["alpha", "delta", "echo", "bravo", "charlie"], Table(vm));
Assert.Equal(Expected(), Table(vm));
EndRun(); // charlie never ran: canceled
await sync;
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm)); // the failed one first, then by name
Assert.Equal(SyncStatus.Failed, vm.FilteredRepos[0].Status);
}
[Fact]
public async Task ASecondRun_KeepsWhatTheFirstFinished_AboveWhatItWaitsFor_AndTheRetriedRowGoesOnTop()
{
WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie");
Task first = vm.SyncCommand.ExecuteAsync(null);
Report("alpha", SyncStatus.Cloning);
Report("alpha", SyncStatus.Done);
Report("bravo", SyncStatus.Cloning);
Report("bravo", SyncStatus.Failed);
Report("charlie", SyncStatus.Cloning);
Report("charlie", SyncStatus.Done);
EndRun();
await first;
Assert.Equal(["bravo", "alpha", "charlie"], Table(vm));
Task retry = vm.RetryFailedCommand.ExecuteAsync(null); // bravo alone
Assert.Equal(["bravo"], _runNames);
Assert.Equal(["charlie", "alpha", "bravo"], Table(vm)); // finished (the latest first), then the one waiting
Report("bravo", SyncStatus.Pulling);
Assert.Equal(["bravo", "charlie", "alpha"], Table(vm));
Report("bravo", SyncStatus.Done);
Assert.Equal(["bravo", "charlie", "alpha"], Table(vm)); // now the latest of the finished
EndRun();
await retry;
Assert.Equal(["alpha", "bravo", "charlie"], Table(vm));
}
[Fact]
public async Task ARunThatStopsOnAnError_LeavesTheTableInTheOrderOfNoRun()
{
WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie");
Task sync = vm.SyncCommand.ExecuteAsync(null);
Report("charlie", SyncStatus.Cloning);
Report("charlie", SyncStatus.Done);
Report("bravo", SyncStatus.Cloning);
Report("bravo", SyncStatus.Failed);
Assert.Equal(["bravo", "charlie", "alpha"], Table(vm));
FailRun();
await sync;
Assert.Equal(RunResultKind.Error, vm.ResultKind);
Assert.Equal(["bravo", "alpha", "charlie"], Table(vm));
Assert.Equal(Expected(), Table(vm));
}
/// <summary>
/// Many runs of random steps: starts with a random selection, reports in any
/// order the engine could make them, filters, ends and failures. After every step
/// the table is what the rule says, row for row.
/// </summary>
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(7)]
[InlineData(8)]
public async Task WhateverHappens_InWhateverOrder_TheTableIsWhatTheRuleSays(int seed)
{
string[] names = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"];
WorkspaceViewModel vm = await LoadAsync(names);
var random = new Random(seed);
RepoFilter[] filters = [RepoFilter.All, RepoFilter.Active, RepoFilter.Failed, RepoFilter.Pending];
Task? sync = null;
for(int step = 0; step < 400; step++)
{
int roll = random.Next(100);
if(roll < 8)
{
vm.Filter = filters[random.Next(filters.Length)];
}
else if(_run is null)
{
foreach(RepoItemViewModel row in vm.Repos)
{
row.IsSelected = random.Next(10) < 7;
}
vm.Repos[random.Next(vm.Repos.Count)].IsSelected = true;
sync = vm.SyncCommand.ExecuteAsync(null);
}
else if(roll < 14)
{
EndRun();
await sync!;
}
else if(roll < 17)
{
FailRun();
await sync!;
}
else
{
string name = _runNames[random.Next(_runNames.Count)];
SyncStatus next = _rows[name].Status switch
{
SyncStatus.Queued => random.Next(2) == 0 ? SyncStatus.Cloning : SyncStatus.Pulling,
SyncStatus.Cloning or SyncStatus.Pulling => random.Next(10) switch
{
< 6 => SyncStatus.Done,
< 9 => SyncStatus.Failed,
_ => SyncStatus.Canceled,
},
SyncStatus status => status, // final: the engine reports nothing more for it
};
if(next != _rows[name].Status)
{
Report(name, next);
}
}
string[] table = Table(vm);
Assert.True(
Expected(vm.Filter).SequenceEqual(table, StringComparer.Ordinal),
$"seed {seed}, step {step}, filter {vm.Filter}, running {_run is not null}:\n expected {string.Join(", ", Expected(vm.Filter))}\n table {string.Join(", ", table)}");
}
}
}
+108 -15
View File
@@ -456,7 +456,25 @@ public sealed class WorkspaceViewModelTests : IDisposable
// ---------------------------------------------------------------- the table's default order (#168)
private static string[] TableOrder(WorkspaceViewModel vm) => [.. vm.FilteredRepos.Select(r => r.Name)];
/// <summary>
/// Waits until the table reads <paramref name="names"/>, top to bottom. The table
/// changes on the engine's thread in these tests, a moment after the status that
/// the test waited for: asking for the order once could catch it mid-move.
/// </summary>
private static Task TableReadsAsync(WorkspaceViewModel vm, params string[] names)
=> WaitUntilAsync(
() =>
{
try
{
return vm.FilteredRepos.Select(r => r.Name).SequenceEqual(names, StringComparer.Ordinal);
}
catch(InvalidOperationException)
{
return false; // changed while it was read: look again
}
},
"the table to read " + string.Join(", ", names));
@@ -483,22 +501,22 @@ public sealed class WorkspaceViewModelTests : IDisposable
Task run = vm.SyncCommand.ExecuteAsync(null);
await WaitUntilAsync(() => byName["alpha"].Status == SyncStatus.Cloning, "alpha to start");
Assert.Equal(["alpha", "bravo", "charlie", "delta"], TableOrder(vm));
await TableReadsAsync(vm, "alpha", "bravo", "charlie", "delta");
gates["alpha"].SetResult();
await WaitUntilAsync(() => byName["bravo"].Status == SyncStatus.Cloning, "bravo to start");
Assert.Equal(["bravo", "alpha", "charlie", "delta"], TableOrder(vm)); // in flight, finished, waiting
await TableReadsAsync(vm, "bravo", "alpha", "charlie", "delta"); // in flight, finished, waiting
gates["bravo"].SetResult();
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
Assert.Equal(["charlie", "bravo", "alpha", "delta"], TableOrder(vm)); // bravo finished after alpha: it is above it
await TableReadsAsync(vm, "charlie", "bravo", "alpha", "delta"); // bravo finished after alpha: it is above it
gates["charlie"].SetResult();
gates["delta"].SetResult();
await run;
// The run is over: by name again, and every row is in the table exactly once.
Assert.Equal(["alpha", "bravo", "charlie", "delta"], TableOrder(vm));
await TableReadsAsync(vm, "alpha", "bravo", "charlie", "delta");
Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status));
}
@@ -518,12 +536,12 @@ public sealed class WorkspaceViewModelTests : IDisposable
gates["bravo"].SetException(new InvalidOperationException("boom"));
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm)); // in flight, failed, finished
await TableReadsAsync(vm, "charlie", "bravo", "alpha"); // in flight, failed, finished
gates["charlie"].SetResult();
await run;
Assert.Equal(["bravo", "alpha", "charlie"], TableOrder(vm)); // the failed one first, then by name
await TableReadsAsync(vm, "bravo", "alpha", "charlie"); // the failed one first, then by name
}
@@ -533,7 +551,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
{
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("Alpha"), Repo("bravo"));
Assert.Equal(["Alpha", "bravo", "charlie"], TableOrder(vm));
await TableReadsAsync(vm, "Alpha", "bravo", "charlie");
Assert.Null(vm.SortColumn);
}
@@ -550,7 +568,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
vm.SortCommand.Execute("Name");
Assert.Equal("Name", vm.SortColumn);
Assert.True(vm.SortDescending);
Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm));
await TableReadsAsync(vm, "charlie", "bravo", "alpha");
// With a column chosen, a sync does not move the rows. It takes them in the
// table's order, so charlie is first, and when it is done bravo runs where it is.
@@ -558,19 +576,19 @@ public sealed class WorkspaceViewModelTests : IDisposable
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
gates["charlie"].SetResult();
await WaitUntilAsync(() => byName["bravo"].Status == SyncStatus.Cloning, "bravo to start");
Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm));
await TableReadsAsync(vm, "charlie", "bravo", "alpha");
// The third click gives the column up: the default order is back, with the row
// in flight on top, then the finished one, then the one still waiting.
vm.SortCommand.Execute("Name");
Assert.Null(vm.SortColumn);
Assert.False(vm.SortDescending);
Assert.Equal(["bravo", "charlie", "alpha"], TableOrder(vm));
await TableReadsAsync(vm, "bravo", "charlie", "alpha");
gates["bravo"].SetResult();
gates["alpha"].SetResult();
await run;
Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm));
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
}
@@ -585,16 +603,16 @@ public sealed class WorkspaceViewModelTests : IDisposable
vm.Filter = RepoFilter.Pending;
Task run = vm.SyncCommand.ExecuteAsync(null);
await WaitUntilAsync(() => byName["alpha"].Status == SyncStatus.Cloning, "alpha to start");
Assert.Equal(["bravo", "charlie"], TableOrder(vm)); // alpha is no longer waiting
await TableReadsAsync(vm, "bravo", "charlie"); // alpha is no longer waiting
vm.Filter = RepoFilter.All;
Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm));
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
gates["alpha"].SetResult();
gates["bravo"].SetResult();
gates["charlie"].SetResult();
await run;
Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm));
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
}
@@ -2972,4 +2990,79 @@ public sealed class WorkspaceViewModelTests : IDisposable
Assert.True(vm.HasFailedRepos);
Assert.Equal(RunResultKind.PartialFailure, vm.ResultKind);
}
// ---------------------------------------------------------------- review of #169
[Fact]
public async Task AFolderThatDoesNotExistYet_IsSaidInTheStatusLine_WhenItIsAskedFor()
{
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"));
vm.NoteFolderNotThereYet();
Assert.Equal($"The folder '{vm.EffectiveTargetRoot}' does not exist yet. The first sync creates it.", vm.StatusText);
}
[Fact]
public async Task ARowInPathRecovery_IsOnTopWhileItRuns_AndBackInItsPlaceAfterwards()
{
MakeAlphaFailWithInvalidPaths();
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("aardvark"), Repo("alpha"), Repo("charlie"));
await vm.SyncCommand.ExecuteAsync(null);
var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal);
byName["aardvark"].Status = SyncStatus.Failed; // a failed row that comes before alpha by name
vm.Filter = RepoFilter.Failed;
vm.Filter = RepoFilter.All;
Assert.Equal(["aardvark", "alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
var inRecovery = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var letGo = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.ApplyRecoveryHandler = async (_, _, _) =>
{
inRecovery.SetResult();
await letGo.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
};
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.Apply(
new PathRecovery(new Dictionary<string, string>(StringComparer.Ordinal), new HashSet<string>(StringComparer.Ordinal))));
Task resolve = vm.ResolvePathsCommand.ExecuteAsync(byName["alpha"]);
await inRecovery.Task.WaitAsync(TimeSpan.FromSeconds(30));
Assert.Equal(SyncStatus.Pulling, byName["alpha"].Status);
Assert.Equal(["alpha", "aardvark", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); // in flight: above the failed row
letGo.SetResult();
await resolve;
Assert.Equal(SyncStatus.Done, byName["alpha"].Status);
Assert.Equal(["aardvark", "alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); // the failed row first, then by name
}
[Fact]
public async Task ARowWhoseRecoveryWasOpen_WhenTheTableWasLoadedAgain_DoesNotComeBackAsAGhost()
{
MakeAlphaFailWithInvalidPaths();
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel old = vm.Repos.Single(r => r.HasPathIssue);
vm.SortCommand.Execute("Name"); // with a column chosen the old row was looked up in the new table's index
vm.RecoveryInteraction = async _ =>
{
// The dialog is open; the user reloads the table behind it, then applies.
await vm.LoadReposCommand.ExecuteAsync(null);
return new PathRecoveryDecision.Apply(
new PathRecovery(new Dictionary<string, string>(StringComparer.Ordinal), new HashSet<string>(StringComparer.Ordinal)));
};
await vm.ResolvePathsCommand.ExecuteAsync(old);
Assert.DoesNotContain(old, vm.FilteredRepos);
Assert.Equal(["alpha", "bravo"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
Assert.Equal(vm.Repos, vm.FilteredRepos);
}
}
+2 -2
View File
@@ -67,8 +67,8 @@ public sealed class RepoTableTests : UiTestBase
header.Patterns.Invoke.Pattern.Invoke();
Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort");
header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over
Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort");
header.Patterns.Invoke.Pattern.Invoke(); // the third step gives the column up: the table's default order is back
Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the header after a third sort");
}
+14 -1
View File
@@ -155,9 +155,22 @@ public sealed class BatchedRepoProgress : IProgress<RepoProgress>, IDisposable
_deliveryScheduled = false;
}
// Every report of the batch is handed over, even when the handler fails for
// one of them: the others are final states of other repositories, and losing
// them would leave rows saying 'cloning' for ever. The first failure is then
// thrown where a single failing report would have thrown it.
System.Runtime.ExceptionServices.ExceptionDispatchInfo? failure = null;
foreach(RepoProgress report in LatestPerRepository(batch))
{
_handler(report);
try
{
_handler(report);
}
catch(Exception ex)
{
failure ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex);
}
}
failure?.Throw();
}
}
+25 -4
View File
@@ -33,6 +33,13 @@ public sealed class ResponsivenessMonitor : IDisposable
/// <summary>After a report, how long further stalls are collected before they are reported together.</summary>
public static readonly TimeSpan QuietPeriod = TimeSpan.FromSeconds(10);
/// <summary>
/// When this much time lies between two looks, the whole process was not running
/// (the machine slept, a debugger held it): the question in flight says nothing
/// about the window and is dropped.
/// </summary>
public static readonly TimeSpan PauseAfter = TimeSpan.FromSeconds(2);
private readonly Func<Action, bool> _post;
private readonly IActivityLog _log;
@@ -69,6 +76,12 @@ public sealed class ResponsivenessMonitor : IDisposable
private TimeSpan _collectedLongest;
/// <summary>What was running at the latest collected stall.</summary>
private string? _collectedActivity;
/// <summary>When <see cref="Check"/> last ran; null before the first look.</summary>
private long? _lookedAt;
private volatile string? _activity;
@@ -149,6 +162,13 @@ public sealed class ResponsivenessMonitor : IDisposable
_quietSince = null;
}
bool wasPaused = _lookedAt is long last && _time.GetElapsedTime(last) >= PauseAfter;
_lookedAt = _time.GetTimestamp();
if(wasPaused)
{
_asked = false;
}
if(_asked)
{
if(!_answered)
@@ -157,7 +177,7 @@ public sealed class ResponsivenessMonitor : IDisposable
if(soFar >= StillWaitingAfter && !_saidStillWaiting)
{
_saidStillWaiting = true;
_log.Error($"The window has not responded for {Milliseconds(soFar)} ms{WhileWhat()} and still does not.");
_log.Error($"The window has not responded for {Milliseconds(soFar)} ms{WhileWhat(_activity)} and still does not.");
}
return;
}
@@ -205,12 +225,13 @@ public sealed class ResponsivenessMonitor : IDisposable
{
if(_quietSince is null)
{
_log.Info($"The window did not respond for {Milliseconds(waited)} ms{WhileWhat()}.");
_log.Info($"The window did not respond for {Milliseconds(waited)} ms{WhileWhat(_activity)}.");
_quietSince = _time.GetTimestamp();
return;
}
_collected++;
_collectedActivity = _activity;
_collectedTotal += waited;
if(waited > _collectedLongest)
{
@@ -229,7 +250,7 @@ public sealed class ResponsivenessMonitor : IDisposable
string times = _collected == 1 ? "once more" : $"{_collected.ToString(CultureInfo.InvariantCulture)} more times";
_log.Info(
$"Since then the window did not respond {times}{WhileWhat()}: {Milliseconds(_collectedTotal)} ms in total, "
$"Since then the window did not respond {times}{WhileWhat(_collectedActivity)}: {Milliseconds(_collectedTotal)} ms in total, "
+ $"the longest {Milliseconds(_collectedLongest)} ms.");
_collected = 0;
_collectedTotal = TimeSpan.Zero;
@@ -238,7 +259,7 @@ public sealed class ResponsivenessMonitor : IDisposable
private string WhileWhat() => _activity is { Length: > 0 } activity ? $" while {activity}" : "";
private static string WhileWhat(string? activity) => activity is { Length: > 0 } ? $" while {activity}" : "";
+3 -1
View File
@@ -194,8 +194,10 @@ public sealed partial class SyncAllStatus : ObservableObject
return;
}
// A run that has just begun still carries the counts of the one before for a
// moment (more done than there is to do): that is 'starting', not a fraction.
string doing;
if(workspace.IsRunning)
if(workspace.IsRunning && workspace.CompletedCount <= workspace.TotalCount)
{
doing = $"{workspace.CompletedCount.ToString(CultureInfo.InvariantCulture)} of "
+ (workspace.TotalCount == 1 ? "1 repository" : $"{workspace.TotalCount.ToString(CultureInfo.InvariantCulture)} repositories");
+20 -1
View File
@@ -660,11 +660,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Records that the user opened the target folder from the results bar (#40).</summary>
/// <summary>Records that the user opened the target folder, from the toolbar or the results bar (#40).</summary>
public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'.");
/// <summary>
/// The user asked for the target folder and it does not exist: says so in the
/// status line. Nothing has been synced into it yet; the first sync creates it.
/// </summary>
public void NoteFolderNotThereYet()
{
StatusText = $"The folder '{EffectiveTargetRoot}' does not exist yet. The first sync creates it.";
_log.Info($"Folder '{EffectiveTargetRoot}' was asked for and does not exist yet.");
}
/// <summary>Asks every row to re-evaluate its theme-dependent presentation (see <see cref="RepoItemViewModel.RefreshPresentation"/>).</summary>
public void RefreshPresentation()
{
@@ -1590,6 +1602,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// </summary>
private void PlaceInTable(RepoItemViewModel item)
{
if(!_reposIndex.ContainsKey(item))
{
// A row from before a reload (its path recovery was still open when the
// table was loaded again): it has no place in this table.
return;
}
bool visible = MatchesFilter(item);
int current = _filteredSet.Contains(item) ? FilteredRepos.IndexOf(item) : -1;
if(!visible && current < 0)
+1 -2
View File
@@ -123,8 +123,6 @@
Filled from SyncAllStatus in code-behind. -->
<Grid
x:Name="SyncAllBar"
AutomationProperties.AutomationId="SyncAllBar"
AutomationProperties.Name="Sync all"
Grid.Row="0"
Margin="20,20,20,0"
MinHeight="48"
@@ -161,6 +159,7 @@
Grid.Column="2"
VerticalAlignment="Center"
AutomationProperties.AutomationId="SyncAllMessage"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis" />
<!-- A StackPanel, so a hidden control leaves no gap behind. -->
+28 -12
View File
@@ -990,6 +990,7 @@ public sealed partial class MainWindow : Window
workspace.Page?.Detach();
workspace.ViewModel.Dispose();
_workspaces.Remove(id);
_reportActivity(DescribeActivity()); // its sync, if one ran, is no longer what is running
if(_navItems.TryGetValue(id, out NavigationViewItem? item))
{
@@ -1003,7 +1004,8 @@ public sealed partial class MainWindow : Window
/// <summary>
/// The 'Sync all' footer command: runs every account workspace sequentially in
/// pane order via <see cref="SyncAllCoordinator"/>, then shows a short summary.
/// pane order via <see cref="SyncAllCoordinator"/>; the bar above the workspace
/// says where the pass stands and, afterwards, how it ended.
/// A second activation while running cancels the queue after the in-flight
/// account (that account's own sync is left to finish).
/// </summary>
@@ -1059,9 +1061,8 @@ public sealed partial class MainWindow : Window
catch(Exception ex)
{
// Unexpected only: the coordinator reports per-account trouble by
// skipping, and DialogGuard turns a blocked summary dialog into a
// no-op instead of a throw; the activity log carries the
// per-account outcomes either way.
// skipping. The bar already says that the pass stopped; the activity
// log carries the per-account outcomes either way.
_log.Error($"Sync all did not finish cleanly: {ex.Message}", ex);
}
finally
@@ -1096,14 +1097,8 @@ public sealed partial class MainWindow : Window
private void RenderSyncAllBar()
{
SyncAllStatus status = _syncAllStatus;
if(!string.Equals(SyncAllTitleText.Text, status.Title, StringComparison.Ordinal))
{
// The headline changes when the pass moves on or ends, not with every
// repository: that is what a screen reader should hear.
SyncAllTitleText.Text = status.Title;
Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(SyncAllTitleText)?
.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
}
bool headlineChanged = !string.Equals(SyncAllTitleText.Text, status.Title, StringComparison.Ordinal);
SyncAllTitleText.Text = status.Title;
SyncAllMessageText.Text = status.Message;
(SyncAllIcon.Glyph, SyncAllIcon.Foreground) = StatusFormat.IconFor(status.Outcome);
@@ -1120,6 +1115,27 @@ public sealed partial class MainWindow : Window
SyncAllCloseButton.Visibility = status.IsRunning ? Visibility.Collapsed : Visibility.Visible;
SyncAllBar.Visibility = status.IsOpen ? Visibility.Visible : Visibility.Collapsed;
if(headlineChanged && status.IsOpen)
{
// The headline changes when the pass moves on or ends, not with every
// repository: that is when a screen reader should speak, and then it reads
// the detail too (which account; how the pass ended). Raised once the bar is
// on screen, or there is nothing to read.
AnnounceLiveRegion(SyncAllTitleText);
AnnounceLiveRegion(SyncAllMessageText);
}
}
/// <summary>Tells assistive technology that a live region's text changed; XAML never does so by itself.</summary>
private static void AnnounceLiveRegion(TextBlock text)
{
Microsoft.UI.Xaml.Automation.Peers.AutomationPeer? peer =
Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(text)
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(text);
peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
}
+1
View File
@@ -449,6 +449,7 @@
<Button
AutomationProperties.AutomationId="ResultActionButton"
Content="{x:Bind ResultActionLabel(ViewModel.HasFailedRepos), Mode=OneWay}"
IsEnabled="{x:Bind ResultActionEnabled(ViewModel.HasFailedRepos, ViewModel.IsRunning, ViewModel.IsLoadingRepos, ViewModel.IsResolvingPaths), Mode=OneWay}"
Click="ResultActionButton_Click" />
</InfoBar.ActionButton>
</InfoBar>
+24 -7
View File
@@ -146,8 +146,9 @@ public sealed partial class WorkspacePage : UserControl
// ---- TableView bridge (#33): the control raises sort requests, the view model
// decides. Sorting (first and second click) and ClearSorting (the control's
// third-click "unsorted" step) both hand the column to SortCommand, which
// cycles ascending <-> descending; the indicator and the header's automation
// name are then written back from SortColumn/SortDescending.
// goes ascending, descending, then back to the table's default order; the
// indicator and the header's automation name are then written back from
// SortColumn/SortDescending.
private void RepoTable_Sorting(object sender, TableViewSortingEventArgs e)
{
@@ -501,17 +502,29 @@ public sealed partial class WorkspacePage : UserControl
/// <summary>
/// Whether the result bar's action can be used now: a retry cannot while a sync, a
/// load, or a path recovery is running (the same rule as the retry command's own);
/// the folder always can.
/// </summary>
public bool ResultActionEnabled(bool hasFailedRepos, bool isRunning, bool isLoadingRepos, bool isResolvingPaths)
=> !hasFailedRepos || !(isRunning || isLoadingRepos || isResolvingPaths);
/// <summary>The result bar's action, as labeled by <see cref="ResultActionLabel"/>.</summary>
private void ResultActionButton_Click(object sender, RoutedEventArgs e)
{
if(ViewModel.HasFailedRepos)
{
ViewModel.RetryFailedCommand.Execute(null);
}
else
if(!ViewModel.HasFailedRepos)
{
OpenFolderButton_Click(sender, e);
}
else if(ViewModel.RetryFailedCommand.CanExecute(null))
{
// Execute alone does not ask: a retry during a path recovery would start a
// sync beside the checkout that recovery is running.
ViewModel.RetryFailedCommand.Execute(null);
}
}
@@ -524,5 +537,9 @@ public sealed partial class WorkspacePage : UserControl
ViewModel.NoteFolderOpened();
await Windows.System.Launcher.LaunchFolderPathAsync(root);
}
else
{
ViewModel.NoteFolderNotThereYet(); // said in the status line: a button that does nothing explains nothing
}
}
}