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:
1 parent
f426a2f7b5
commit
ae81e0097b
15 files changed
+692
-56
No files matched your search
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}" : "";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user