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 Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59

-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 @@