diff --git a/README.md b/README.md
index d174ac5..3efc2b6 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
-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//` inside WSL (reachable from Explorer as `\wsl.localhost\\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`).
diff --git a/gclo.Engine.Tests/BatchedRepoProgressTests.cs b/gclo.Engine.Tests/BatchedRepoProgressTests.cs
index 5281ba4..7b4107c 100644
--- a/gclo.Engine.Tests/BatchedRepoProgressTests.cs
+++ b/gclo.Engine.Tests/BatchedRepoProgressTests.cs
@@ -180,6 +180,33 @@ public sealed class BatchedRepoProgressTests
+ [Fact]
+ public void AHandlerThatFailsForOneReport_StillGetsTheOthers_AndTheFailureIsNotSwallowed()
+ {
+ var delivered = new List();
+ 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(() => _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()
{
diff --git a/gclo.Engine.Tests/ResponsivenessMonitorTests.cs b/gclo.Engine.Tests/ResponsivenessMonitorTests.cs
index d0c0261..e615fb5 100644
--- a/gclo.Engine.Tests/ResponsivenessMonitorTests.cs
+++ b/gclo.Engine.Tests/ResponsivenessMonitorTests.cs
@@ -51,12 +51,27 @@ public sealed class ResponsivenessMonitorTests
- /// The window answers at once, and nothing happens until the quiet period after a report is over.
+ /// The monitor looks every 100 ms, as its timer would, for the given time; nothing answers.
+ private void Looks(ResponsivenessMonitor monitor, TimeSpan during)
+ {
+ for(TimeSpan passed = TimeSpan.Zero; passed < during; passed += ResponsivenessMonitor.DefaultInterval)
+ {
+ _time.Advance(ResponsivenessMonitor.DefaultInterval);
+ monitor.Check();
+ }
+ }
+
+
+
+ /// The window answers every question at once until the quiet period after a report is over.
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()
{
diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
index a8ab7d9..447be17 100644
--- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
+++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
@@ -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();
+ var said = new List();
+ 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()
{
diff --git a/gclo.Engine.Tests/TableOrderTests.cs b/gclo.Engine.Tests/TableOrderTests.cs
new file mode 100644
index 0000000..83fa727
--- /dev/null
+++ b/gclo.Engine.Tests/TableOrderTests.cs
@@ -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;
+
+
+///
+/// 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.
+///
+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 _made = [];
+
+ /// What the test knows about each row: its status, and when it last started or finished.
+ private readonly Dictionary _rows = new(StringComparer.Ordinal);
+
+ private TaskCompletionSource? _run;
+
+ private IProgress? _progress;
+
+ private List _runNames = [];
+
+ private long _clock;
+
+
+
+ public void Dispose()
+ {
+ foreach(WorkspaceViewModel vm in _made)
+ {
+ vm.Dispose();
+ }
+ TryDeleteDirectory(_folder);
+ }
+
+
+
+ /// Hands each report to the view model at once, on the reporting (test) thread.
+ private sealed class Inline(Action handler) : IProgress
+ {
+ public void Report(RepoProgress value) => handler(value);
+ }
+
+
+
+ /// A loaded workspace whose sync runs are driven by the test, report by report.
+ private async Task 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();
+ _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));
+ }
+
+
+
+ /// Ends the run in flight; rows that never finished are reported canceled, as the engine does.
+ 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 run = _run!;
+ _run = null;
+ run.SetResult(new SyncSummary(_runNames.Count, 0, 0, 0, 0, WasCanceled: false));
+ }
+
+
+
+ /// The run in flight stops on an error, with its rows left as they are.
+ private void FailRun()
+ {
+ TaskCompletionSource run = _run!;
+ _run = null;
+ run.SetException(new InvalidOperationException("the run failed"));
+ }
+
+
+
+ /// The rule, written out: which names the table shows, in which order.
+ 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));
+ }
+
+
+
+ ///
+ /// 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.
+ ///
+ [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)}");
+ }
+ }
+}
diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
index 1814fce..4d9b682 100644
--- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs
+++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
@@ -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)];
+ ///
+ /// Waits until the table reads , 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.
+ ///
+ 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(new PathRecoveryDecision.Apply(
+ new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(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(StringComparer.Ordinal), new HashSet(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);
+ }
}
diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs
index 0811e96..6b42cf0 100644
--- a/gclo.UiTests/RepoTableTests.cs
+++ b/gclo.UiTests/RepoTableTests.cs
@@ -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");
}
diff --git a/gclo.ViewModels/BatchedRepoProgress.cs b/gclo.ViewModels/BatchedRepoProgress.cs
index 98d3f09..72d6d16 100644
--- a/gclo.ViewModels/BatchedRepoProgress.cs
+++ b/gclo.ViewModels/BatchedRepoProgress.cs
@@ -155,9 +155,22 @@ public sealed class BatchedRepoProgress : IProgress, 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();
}
}
diff --git a/gclo.ViewModels/ResponsivenessMonitor.cs b/gclo.ViewModels/ResponsivenessMonitor.cs
index 9f6c783..4f814b3 100644
--- a/gclo.ViewModels/ResponsivenessMonitor.cs
+++ b/gclo.ViewModels/ResponsivenessMonitor.cs
@@ -33,6 +33,13 @@ public sealed class ResponsivenessMonitor : IDisposable
/// After a report, how long further stalls are collected before they are reported together.
public static readonly TimeSpan QuietPeriod = TimeSpan.FromSeconds(10);
+ ///
+ /// 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.
+ ///
+ public static readonly TimeSpan PauseAfter = TimeSpan.FromSeconds(2);
+
private readonly Func _post;
private readonly IActivityLog _log;
@@ -69,6 +76,12 @@ public sealed class ResponsivenessMonitor : IDisposable
private TimeSpan _collectedLongest;
+ /// What was running at the latest collected stall.
+ private string? _collectedActivity;
+
+ /// When last ran; null before the first look.
+ 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}" : "";
diff --git a/gclo.ViewModels/SyncAllStatus.cs b/gclo.ViewModels/SyncAllStatus.cs
index 6b4f938..0245b83 100644
--- a/gclo.ViewModels/SyncAllStatus.cs
+++ b/gclo.ViewModels/SyncAllStatus.cs
@@ -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");
diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs
index 1c92cae..2481061 100644
--- a/gclo.ViewModels/WorkspaceViewModel.cs
+++ b/gclo.ViewModels/WorkspaceViewModel.cs
@@ -660,11 +660,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
- /// Records that the user opened the target folder from the results bar (#40).
+ /// Records that the user opened the target folder, from the toolbar or the results bar (#40).
public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'.");
+ ///
+ /// 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.
+ ///
+ 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.");
+ }
+
+
+
/// Asks every row to re-evaluate its theme-dependent presentation (see ).
public void RefreshPresentation()
{
@@ -1590,6 +1602,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
///
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)
diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml
index 52ec108..648c391 100644
--- a/gclo/MainWindow.xaml
+++ b/gclo/MainWindow.xaml
@@ -123,8 +123,6 @@
Filled from SyncAllStatus in code-behind. -->
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index 3308090..8030d31 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -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
///
/// The 'Sync all' footer command: runs every account workspace sequentially in
- /// pane order via , then shows a short summary.
+ /// pane order via ; 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).
///
@@ -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);
+ }
+ }
+
+
+
+ /// Tells assistive technology that a live region's text changed; XAML never does so by itself.
+ 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);
}
diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml
index b65fe59..2e86760 100644
--- a/gclo/WorkspacePage.xaml
+++ b/gclo/WorkspacePage.xaml
@@ -449,6 +449,7 @@
diff --git a/gclo/WorkspacePage.xaml.cs b/gclo/WorkspacePage.xaml.cs
index 349ed1d..3c3b5f2 100644
--- a/gclo/WorkspacePage.xaml.cs
+++ b/gclo/WorkspacePage.xaml.cs
@@ -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
+ ///
+ /// 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.
+ ///
+ public bool ResultActionEnabled(bool hasFailedRepos, bool isRunning, bool isLoadingRepos, bool isResolvingPaths)
+ => !hasFailedRepos || !(isRunning || isLoadingRepos || isResolvingPaths);
+
+
+
/// The result bar's action, as labeled by .
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
+ }
}
}