fix(app): the window stays responsive during a sync, Sync all says where it stands, and the table follows the sync #169
No files matched your search
@@ -97,26 +97,26 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
|
||||
2. **Pick the organization or account** from the dropdown.
|
||||
3. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `<org>` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`).
|
||||
4. **Set parallelism** if you like (1–64, default 8): how many git operations run at once.
|
||||
5. **Press Load repos.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip); use the funnel on any column to filter by name, status, branch, or archived state.
|
||||
5. **Press Load repos.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip, a third time to return to the default order); use the funnel on any column to filter by name, status, branch, or archived state.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/gclo-repos.png" width="820" alt="Loaded repository table: 59 repositories all selected, with sortable, filterable Name / Status / Branch / Archived columns and a Sync 59 repos button" />
|
||||
</p>
|
||||
|
||||
6. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once.
|
||||
7. **Press Sync selected.** Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`).
|
||||
7. **Press Sync selected.** Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). By default the table follows the sync, so what is happening is always in view: repositories in progress are on top, then the failed ones, then the finished ones with the latest first, then the ones still waiting. When the sync is over, the failed ones come first and the rest are in name order. A column you sorted by yourself stays as you set it.
|
||||
|
||||
<p align="center">
|
||||
<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, **Retry failed** re-runs exactly the failed rows, and **Open folder** opens the target folder in Explorer.
|
||||
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`).
|
||||
|
||||
**Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
|
||||
|
||||
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters and per-repo failures and never contain your token (see [SECURITY.md](SECURITY.md)).
|
||||
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters, per-repo failures, and when the window stopped responding (for how long, and what was running), and never contain your token (see [SECURITY.md](SECURITY.md)).
|
||||
|
||||
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
|
||||
|
||||
@@ -124,7 +124,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
|
||||
|
||||
### Saved accounts
|
||||
|
||||
Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn, and the pane shows each account's live status.
|
||||
Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name and optional description" />
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="BatchedRepoProgress"/> holds progress reports for a moment and hands
|
||||
/// them to the window together, the latest per repository, so thousands of reports
|
||||
/// do not cost the window's thread thousands of row updates (#167).
|
||||
/// </summary>
|
||||
public sealed class BatchedRepoProgressTests
|
||||
{
|
||||
private static readonly TimeSpan s_interval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly ManualTime _time = new();
|
||||
|
||||
private readonly PumpedContext _window = new();
|
||||
|
||||
private readonly List<RepoProgress> _delivered = [];
|
||||
|
||||
|
||||
|
||||
private BatchedRepoProgress Create() => new(_delivered.Add, _window, s_interval, _time);
|
||||
|
||||
|
||||
|
||||
private ManualTime.ManualTimer Timer => Assert.Single(_time.Timers);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Reports_Wait_AndAreDeliveredTogether_OnTheWindowsThread_WhenTheTimerFires()
|
||||
{
|
||||
using BatchedRepoProgress progress = Create();
|
||||
Assert.False(Timer.IsArmed); // nothing to deliver yet
|
||||
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Cloning));
|
||||
progress.Report(new RepoProgress("bravo", SyncStatus.Pulling));
|
||||
|
||||
Assert.Empty(_delivered);
|
||||
Assert.True(Timer.IsArmed);
|
||||
Assert.Equal(s_interval, Timer.DueTime);
|
||||
Assert.Equal(1, Timer.Changes); // the second report did not start the wait again
|
||||
|
||||
Timer.Fire(); // on a timer thread: nothing but a post to the window's thread
|
||||
Assert.Empty(_delivered);
|
||||
Assert.Equal(1, _window.Waiting);
|
||||
|
||||
_window.RunAll();
|
||||
Assert.Equal(["alpha", "bravo"], _delivered.Select(r => r.RepoName), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void OfSeveralReportsForOneRepository_OnlyTheLatestIsDelivered_InTheOrderRepositoriesFirstReported()
|
||||
{
|
||||
using BatchedRepoProgress progress = Create();
|
||||
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.10));
|
||||
progress.Report(new RepoProgress("bravo", SyncStatus.Cloning, Percent: 0.50));
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.90));
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Failed, Error: "boom"));
|
||||
Timer.Fire();
|
||||
_window.RunAll();
|
||||
|
||||
Assert.Equal(
|
||||
[new RepoProgress("alpha", SyncStatus.Failed, Error: "boom"), new RepoProgress("bravo", SyncStatus.Cloning, Percent: 0.50)],
|
||||
_delivered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AfterADelivery_TheNextReport_StartsANewWait()
|
||||
{
|
||||
using BatchedRepoProgress progress = Create();
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Cloning));
|
||||
Timer.Fire();
|
||||
_window.RunAll();
|
||||
Assert.False(Timer.IsArmed);
|
||||
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
|
||||
Assert.True(Timer.IsArmed);
|
||||
Assert.Equal(2, Timer.Changes);
|
||||
Timer.Fire();
|
||||
_window.RunAll();
|
||||
Assert.Equal([SyncStatus.Cloning, SyncStatus.Done], _delivered.Select(r => r.Status));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AReportThatArrivesWhileADeliveryIsPosted_GoesWithThatDelivery()
|
||||
{
|
||||
using BatchedRepoProgress progress = Create();
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Cloning));
|
||||
Timer.Fire(); // posted, not run yet
|
||||
|
||||
progress.Report(new RepoProgress("bravo", SyncStatus.Cloning));
|
||||
Assert.False(Timer.IsArmed); // one delivery is already on its way
|
||||
|
||||
_window.RunAll();
|
||||
Assert.Equal(["alpha", "bravo"], _delivered.Select(r => r.RepoName), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DeliversWhatStillWaits_AtOnce_OnTheCallingThread_AndStopsTheTimer()
|
||||
{
|
||||
BatchedRepoProgress progress = Create();
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
progress.Report(new RepoProgress("bravo", SyncStatus.Failed, Error: "boom"));
|
||||
|
||||
progress.Dispose();
|
||||
|
||||
Assert.Equal(["alpha", "bravo"], _delivered.Select(r => r.RepoName), StringComparer.Ordinal);
|
||||
Assert.Equal(0, _window.Waiting); // not through the queue: the caller is the window's thread
|
||||
Assert.True(Timer.IsDisposed);
|
||||
|
||||
progress.Dispose(); // a second time changes nothing
|
||||
Assert.Equal(2, _delivered.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ADeliveryPostedBeforeDispose_FindsNothingLeft_WhenItRuns()
|
||||
{
|
||||
BatchedRepoProgress progress = Create();
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
Timer.Fire(); // posted
|
||||
progress.Dispose(); // delivered here
|
||||
|
||||
_window.RunAll();
|
||||
|
||||
Assert.Single(_delivered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AReportAfterDispose_IsDeliveredOnItsOwn_ThroughTheWindowsQueue()
|
||||
{
|
||||
BatchedRepoProgress progress = Create();
|
||||
progress.Dispose();
|
||||
|
||||
// An operation that was abandoned and still finished reports after the run.
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
progress.Report(new RepoProgress("bravo", SyncStatus.Done));
|
||||
|
||||
Assert.Empty(_delivered);
|
||||
Assert.Equal(1, _window.Waiting); // one post for both: the first is still on its way
|
||||
_window.RunAll();
|
||||
Assert.Equal(["alpha", "bravo"], _delivered.Select(r => r.RepoName), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task WithNothingGiven_ItUsesTheCallersContext_TheSystemTimer_AndTheDefaultInterval()
|
||||
{
|
||||
var delivered = new TaskCompletionSource<RepoProgress>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var progress = new BatchedRepoProgress(delivered.SetResult);
|
||||
|
||||
progress.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
RepoProgress report = await delivered.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal("alpha", report.RepoName);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(100), BatchedRepoProgress.DefaultInterval);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[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()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new BatchedRepoProgress(null!));
|
||||
using BatchedRepoProgress progress = Create();
|
||||
Assert.Throws<ArgumentNullException>(() => progress.Report(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A clock that moves only when a test moves it, and timers that fire only when a
|
||||
/// test fires them: what happens "after 100 ms" is then a step in the test, not a
|
||||
/// wait that a slow machine can stretch.
|
||||
/// </summary>
|
||||
internal sealed class ManualTime : TimeProvider
|
||||
{
|
||||
private readonly List<ManualTimer> _timers = [];
|
||||
|
||||
private long _now;
|
||||
|
||||
|
||||
|
||||
/// <summary>Every timer created from this clock, in creation order.</summary>
|
||||
public IReadOnlyList<ManualTimer> Timers
|
||||
{
|
||||
get
|
||||
{
|
||||
lock(_timers)
|
||||
{
|
||||
return [.. _timers];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
|
||||
|
||||
public override long GetTimestamp() => Interlocked.Read(ref _now);
|
||||
|
||||
|
||||
|
||||
/// <summary>Moves the clock forward; fires nothing.</summary>
|
||||
public void Advance(TimeSpan by) => Interlocked.Add(ref _now, by.Ticks);
|
||||
|
||||
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ManualTimer(callback, state, dueTime, period);
|
||||
lock(_timers)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
}
|
||||
return timer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>One timer: armed or not, fired by the test.</summary>
|
||||
internal sealed class ManualTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) : ITimer
|
||||
{
|
||||
/// <summary>When it is next due, counted from the last arming; infinite when it is not armed.</summary>
|
||||
public TimeSpan DueTime { get; private set; } = dueTime;
|
||||
|
||||
/// <summary>Its period; infinite for a timer that fires once per arming.</summary>
|
||||
public TimeSpan Period { get; private set; } = period;
|
||||
|
||||
/// <summary>How many times it was armed again after it was created.</summary>
|
||||
public int Changes { get; private set; }
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public bool IsArmed => !IsDisposed && DueTime != Timeout.InfiniteTimeSpan;
|
||||
|
||||
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
if(IsDisposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
DueTime = dueTime;
|
||||
Period = period;
|
||||
Changes++;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs the callback as the timer would when due; a timer without a period is then no longer armed.</summary>
|
||||
public void Fire()
|
||||
{
|
||||
Assert.True(IsArmed, "the timer is not armed");
|
||||
if(Period == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
DueTime = Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
callback(state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void Dispose() => IsDisposed = true;
|
||||
|
||||
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A stand-in for the window's thread: what is posted to it waits until the test
|
||||
/// runs it, the way a dispatcher queue holds work until the thread gets to it.
|
||||
/// </summary>
|
||||
internal sealed class PumpedContext : SynchronizationContext
|
||||
{
|
||||
private readonly Queue<(SendOrPostCallback Callback, object? State)> _posted = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>How many posted callbacks wait to be run.</summary>
|
||||
public int Waiting
|
||||
{
|
||||
get
|
||||
{
|
||||
lock(_posted)
|
||||
{
|
||||
return _posted.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
lock(_posted)
|
||||
{
|
||||
_posted.Enqueue((d, state));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs everything posted so far, on the calling thread; returns how many callbacks ran.</summary>
|
||||
public int RunAll()
|
||||
{
|
||||
int ran = 0;
|
||||
while(true)
|
||||
{
|
||||
(SendOrPostCallback Callback, object? State) next;
|
||||
lock(_posted)
|
||||
{
|
||||
if(_posted.Count == 0)
|
||||
{
|
||||
return ran;
|
||||
}
|
||||
next = _posted.Dequeue();
|
||||
}
|
||||
next.Callback(next.State);
|
||||
ran++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ResponsivenessMonitor"/> asks the window's thread to answer and says
|
||||
/// in the activity log when an answer came late: for how long the window did not
|
||||
/// respond, and what was running (#167).
|
||||
/// </summary>
|
||||
public sealed class ResponsivenessMonitorTests
|
||||
{
|
||||
private readonly ManualTime _time = new();
|
||||
|
||||
private readonly RecordingActivityLog _log = new();
|
||||
|
||||
/// <summary>What the monitor queued on the window's thread and the test has not run yet.</summary>
|
||||
private readonly Queue<Action> _asked = new();
|
||||
|
||||
private bool _windowTakesWork = true;
|
||||
|
||||
|
||||
|
||||
private ResponsivenessMonitor Create()
|
||||
=> new(
|
||||
answer =>
|
||||
{
|
||||
if(_windowTakesWork)
|
||||
{
|
||||
_asked.Enqueue(answer);
|
||||
}
|
||||
return _windowTakesWork;
|
||||
},
|
||||
_log,
|
||||
_time);
|
||||
|
||||
|
||||
|
||||
/// <summary>The window's thread answers the question in flight after <paramref name="after"/>.</summary>
|
||||
private void Answer(TimeSpan after)
|
||||
{
|
||||
_time.Advance(after);
|
||||
_asked.Dequeue()();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
{
|
||||
for(TimeSpan passed = TimeSpan.Zero; passed <= ResponsivenessMonitor.QuietPeriod; passed += ResponsivenessMonitor.DefaultInterval)
|
||||
{
|
||||
Answer(TimeSpan.Zero);
|
||||
_time.Advance(ResponsivenessMonitor.DefaultInterval);
|
||||
monitor.Check();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>One stall of the given length: asked, answered late, noticed.</summary>
|
||||
private void Stall(ResponsivenessMonitor monitor, int milliseconds)
|
||||
{
|
||||
Answer(TimeSpan.FromMilliseconds(milliseconds));
|
||||
monitor.Check();
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AWindowThatAnswersInTime_LeavesNoLine()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
|
||||
monitor.Check(); // asks
|
||||
Assert.Single(_asked);
|
||||
Answer(TimeSpan.FromMilliseconds(40));
|
||||
monitor.Check(); // answered in time: asks again
|
||||
Answer(TimeSpan.FromMilliseconds(249));
|
||||
monitor.Check();
|
||||
|
||||
Assert.Empty(_log.Entries);
|
||||
Assert.Single(_asked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ALateAnswer_IsReported_WithHowLongItTook_AndWhatWasRunning()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
monitor.Check();
|
||||
Stall(monitor, 250);
|
||||
|
||||
monitor.Activity = "syncing 'Engineering'";
|
||||
Assert.Equal("syncing 'Engineering'", monitor.Activity);
|
||||
QuietPeriodPasses(monitor); // the next stall has its own line again
|
||||
Stall(monitor, 1357);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
("INFO", "The window did not respond for 250 ms."),
|
||||
("INFO", "The window did not respond for 1357 ms while syncing 'Engineering'."),
|
||||
],
|
||||
_log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void StallsThatFollowAReportClosely_AreCounted_AndReportedTogether_WhenTheQuietPeriodIsOver()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
monitor.Activity = "syncing 'Engineering'";
|
||||
monitor.Check();
|
||||
Stall(monitor, 400);
|
||||
Stall(monitor, 300);
|
||||
Stall(monitor, 500);
|
||||
Assert.Single(_log.Entries); // only the first has its own line
|
||||
|
||||
QuietPeriodPasses(monitor);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"The window did not respond for 400 ms while syncing 'Engineering'.",
|
||||
"Since then the window did not respond 2 more times while syncing 'Engineering': 800 ms in total, the longest 500 ms.",
|
||||
],
|
||||
_log.Messages);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ASingleFollowingStall_IsReportedAsOnceMore()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
monitor.Check();
|
||||
Stall(monitor, 400);
|
||||
Stall(monitor, 300);
|
||||
|
||||
QuietPeriodPasses(monitor);
|
||||
|
||||
Assert.Equal(
|
||||
"Since then the window did not respond once more: 300 ms in total, the longest 300 ms.",
|
||||
_log.Messages[^1]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AWindowThatStaysSilent_IsReportedOnce_WhileItStillIs_AndAgainWhenItAnswers()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
monitor.Check(); // asks; no answer comes
|
||||
|
||||
Looks(monitor, TimeSpan.FromSeconds(4.9));
|
||||
Assert.Empty(_log.Entries); // not long enough yet
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
{
|
||||
using ResponsivenessMonitor monitor = Create();
|
||||
monitor.Check();
|
||||
Stall(monitor, 400);
|
||||
Answer(TimeSpan.FromMilliseconds(300));
|
||||
_windowTakesWork = false; // the window is closing
|
||||
monitor.Check(); // notes the stall, then finds it cannot ask again
|
||||
|
||||
Assert.Equal(
|
||||
"Since then the window did not respond once more: 300 ms in total, the longest 300 ms.",
|
||||
_log.Messages[^1]);
|
||||
|
||||
int lines = _log.Entries.Count;
|
||||
_windowTakesWork = true;
|
||||
monitor.Check(); // over: it does not start again
|
||||
Assert.Empty(_asked);
|
||||
Assert.Equal(lines, _log.Entries.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ReportsWhatWasCollected_StopsTheTimer_AndIsSafeTwice()
|
||||
{
|
||||
ResponsivenessMonitor monitor = Create();
|
||||
ManualTime.ManualTimer timer = Assert.Single(_time.Timers);
|
||||
Assert.Equal(ResponsivenessMonitor.DefaultInterval, timer.DueTime);
|
||||
Assert.Equal(ResponsivenessMonitor.DefaultInterval, timer.Period);
|
||||
|
||||
timer.Fire(); // the timer is what looks: it asks
|
||||
Assert.Single(_asked);
|
||||
Stall(monitor, 400);
|
||||
Stall(monitor, 300);
|
||||
|
||||
monitor.Dispose();
|
||||
monitor.Dispose();
|
||||
|
||||
Assert.True(timer.IsDisposed);
|
||||
Assert.Equal(2, _log.Entries.Count);
|
||||
Assert.StartsWith("Since then the window did not respond once more", _log.Messages[^1], StringComparison.Ordinal);
|
||||
|
||||
monitor.Check(); // a look that was already on its way finds the watch over
|
||||
Assert.Equal(2, _log.Entries.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WithNothingGiven_ItUsesTheSystemClock_AndItsOwnDefaults()
|
||||
{
|
||||
using var monitor = new ResponsivenessMonitor(_ => true, _log);
|
||||
|
||||
Assert.Null(monitor.Activity);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(250), ResponsivenessMonitor.DefaultThreshold);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), ResponsivenessMonitor.StillWaitingAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AMissingQueueOrLog_IsRefused()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ResponsivenessMonitor(null!, _log));
|
||||
Assert.Throws<ArgumentNullException>(() => new ResponsivenessMonitor(_ => true, null!));
|
||||
}
|
||||
}
|
||||
@@ -548,4 +548,289 @@ public sealed class SyncAllCoordinatorTests : IDisposable
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Running, SyncAllAccountState.Failed], states);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the status a bar shows (#167)
|
||||
|
||||
/// <summary>A clone that waits until the test lets it go, and says when it was entered.</summary>
|
||||
private static (FakeGitClient Git, Task Entered, TaskCompletionSource Gate) GatedGit()
|
||||
{
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var git = new FakeGitClient
|
||||
{
|
||||
CloneHandler = (_, _, _, _, _) =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
return gate.Task;
|
||||
},
|
||||
};
|
||||
return (git, entered.Task, gate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TheStatus_SaysWhichAccountIsRunning_HowFarItIs_AndHowThePassEnded()
|
||||
{
|
||||
(FakeGitClient git, Task entered, TaskCompletionSource gate) = GatedGit();
|
||||
WorkspaceViewModel alpha = CreateWorkspace("alpha", git);
|
||||
WorkspaceViewModel beta = CreateWorkspace("beta");
|
||||
var status = new SyncAllStatus();
|
||||
_coordinator.Status = status;
|
||||
var said = new List<string>();
|
||||
status.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Message), StringComparison.Ordinal))
|
||||
{
|
||||
said.Add(status.Message);
|
||||
}
|
||||
};
|
||||
Assert.False(status.IsOpen);
|
||||
Assert.Equal(SyncAllOutcome.None, status.Outcome);
|
||||
Assert.Equal("", status.Title);
|
||||
|
||||
Task<SyncAllResult> run = _coordinator.RunAsync([alpha, beta], CancellationToken.None);
|
||||
await entered.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.True(status.IsRunning);
|
||||
Assert.True(status.IsOpen);
|
||||
Assert.Equal(SyncAllOutcome.Running, status.Outcome);
|
||||
Assert.Equal("Sync all: account 1 of 2", status.Title);
|
||||
Assert.Equal("alpha: 0 of 1 repository.", status.Message);
|
||||
Assert.Equal(alpha.AccountId, status.CurrentAccountId);
|
||||
Assert.Equal(0d, status.Fraction);
|
||||
|
||||
gate.SetResult();
|
||||
SyncAllResult result = await run.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false), result);
|
||||
Assert.False(status.IsRunning);
|
||||
Assert.True(status.IsOpen); // the result stays until it is closed
|
||||
Assert.Equal(SyncAllOutcome.Finished, status.Outcome);
|
||||
Assert.Equal("Sync all finished", status.Title);
|
||||
Assert.Equal("2 accounts synced, 0 skipped.", status.Message);
|
||||
Assert.Null(status.CurrentAccountId);
|
||||
Assert.Null(status.Fraction);
|
||||
|
||||
// Every step was said, in order: the start, each account's listing and its count.
|
||||
Assert.Equal(
|
||||
[
|
||||
"Starting 2 accounts.",
|
||||
"alpha: starting.",
|
||||
"alpha: listing its repositories.",
|
||||
"alpha: starting.",
|
||||
"alpha: 0 of 1 repository.",
|
||||
"alpha: 1 of 1 repository.",
|
||||
"alpha: starting.",
|
||||
"beta: starting.",
|
||||
"beta: listing its repositories.",
|
||||
"beta: starting.",
|
||||
"beta: 0 of 1 repository.",
|
||||
"beta: 1 of 1 repository.",
|
||||
"beta: starting.",
|
||||
"2 accounts synced, 0 skipped.",
|
||||
],
|
||||
said);
|
||||
|
||||
// The pass is over: what the accounts do next is no longer the bar's business.
|
||||
status.IsOpen = false;
|
||||
await alpha.SyncCommand.ExecuteAsync(null);
|
||||
Assert.Equal("2 accounts synced, 0 skipped.", status.Message);
|
||||
Assert.False(status.IsOpen);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TheStatus_ShowsSeveralRepositories_AsAFraction()
|
||||
{
|
||||
(FakeGitClient git, Task entered, TaskCompletionSource gate) = GatedGit();
|
||||
_lister.Handler = org => [Repo(org + "-one"), Repo(org + "-two")];
|
||||
WorkspaceViewModel alpha = CreateWorkspace("alpha", git);
|
||||
alpha.MaxConcurrencyValue = 1;
|
||||
var status = new SyncAllStatus();
|
||||
_coordinator.Status = status;
|
||||
var fractions = new List<double?>();
|
||||
status.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Fraction), StringComparison.Ordinal))
|
||||
{
|
||||
fractions.Add(status.Fraction);
|
||||
}
|
||||
};
|
||||
|
||||
Task<SyncAllResult> run = _coordinator.RunAsync([alpha], CancellationToken.None);
|
||||
await entered.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Equal("Sync all: account 1 of 1", status.Title);
|
||||
Assert.Equal("alpha: 0 of 2 repositories.", status.Message);
|
||||
gate.SetResult();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Contains(0.5, fractions);
|
||||
Assert.Equal("1 account synced, 0 skipped.", status.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ACancelRequest_IsSaidAtOnce_AndThePassEndsAsCanceled()
|
||||
{
|
||||
(FakeGitClient git, Task entered, TaskCompletionSource gate) = GatedGit();
|
||||
WorkspaceViewModel alpha = CreateWorkspace("alpha", git);
|
||||
WorkspaceViewModel beta = CreateWorkspace("beta");
|
||||
var status = new SyncAllStatus();
|
||||
_coordinator.Status = status;
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
status.RequestCancel(); // nothing runs: nothing to cancel
|
||||
Assert.False(status.IsCancelRequested);
|
||||
|
||||
Task<SyncAllResult> run = _coordinator.RunAsync([alpha, beta], cts.Token);
|
||||
await entered.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
cts.Cancel();
|
||||
status.RequestCancel();
|
||||
|
||||
Assert.True(status.IsCancelRequested);
|
||||
Assert.Equal("alpha: 0 of 1 repository. Stopping when this account is done.", status.Message);
|
||||
|
||||
gate.SetResult();
|
||||
SyncAllResult result = await run.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.True(result.WasCanceled);
|
||||
Assert.Equal(SyncAllOutcome.Canceled, status.Outcome);
|
||||
Assert.Equal("Sync all canceled", status.Title);
|
||||
Assert.Equal("1 account synced, 0 skipped.", status.Message);
|
||||
Assert.False(status.IsRunning);
|
||||
|
||||
// The next pass starts clean.
|
||||
(await _coordinator.RunAsync([beta], CancellationToken.None)).ToString();
|
||||
Assert.False(status.IsCancelRequested);
|
||||
Assert.Equal(SyncAllOutcome.Finished, status.Outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AccountsThatFailed_AreCounted_AndThePassEndsWithFailures()
|
||||
{
|
||||
// One whose repository fails, one that cannot start at all, one that is fine.
|
||||
WorkspaceViewModel failing = CreateWorkspace(
|
||||
"alpha", new FakeGitClient { CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidOperationException("boom")) });
|
||||
WorkspaceViewModel blocked = CreateWorkspace("blocked");
|
||||
Directory.CreateDirectory(_root);
|
||||
File.WriteAllText(Path.Combine(_root, "blocked"), "a file, not a folder");
|
||||
WorkspaceViewModel fine = CreateWorkspace("gamma");
|
||||
var status = new SyncAllStatus();
|
||||
_coordinator.Status = status;
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([failing, blocked, fine], CancellationToken.None);
|
||||
|
||||
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 1, WasCanceled: false, Failed: 2), result);
|
||||
Assert.Equal(SyncAllOutcome.FinishedWithFailures, status.Outcome);
|
||||
Assert.Equal("Sync all finished", status.Title);
|
||||
Assert.Equal("2 accounts synced, 1 skipped. 2 accounts need attention; they are marked in the pane.", status.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ACanceledPass_KeepsTheFailuresItSaw()
|
||||
{
|
||||
WorkspaceViewModel failing = CreateWorkspace(
|
||||
"alpha", new FakeGitClient { CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidOperationException("boom")) });
|
||||
WorkspaceViewModel never = CreateWorkspace("beta");
|
||||
using var cts = new CancellationTokenSource();
|
||||
_coordinator.AccountStateChanged = (_, state) =>
|
||||
{
|
||||
if(state == SyncAllAccountState.Failed)
|
||||
{
|
||||
cts.Cancel(); // as soon as the first account is done
|
||||
}
|
||||
};
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([failing, never], cts.Token);
|
||||
|
||||
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true, Failed: 1), result);
|
||||
Assert.Equal("1 account synced, 0 skipped. 1 account needs attention; it is marked in the pane.", SyncAllStatus.Describe(result));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnexpectedError_EndsTheStatusToo_InsteadOfLeavingItRunning()
|
||||
{
|
||||
WorkspaceViewModel alpha = CreateWorkspace("alpha");
|
||||
var status = new SyncAllStatus();
|
||||
_coordinator.Status = status;
|
||||
_coordinator.AccountStateChanged = (_, _) => throw new InvalidOperationException("the pane is gone");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _coordinator.RunAsync([alpha], CancellationToken.None));
|
||||
|
||||
Assert.False(status.IsRunning);
|
||||
Assert.True(status.IsOpen);
|
||||
Assert.Equal(SyncAllOutcome.Stopped, status.Outcome);
|
||||
Assert.Equal("Sync all stopped", status.Title);
|
||||
Assert.Equal("the pane is gone", status.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[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()
|
||||
{
|
||||
var status = new SyncAllStatus();
|
||||
status.Begin(1);
|
||||
Assert.Equal("Starting 1 account.", status.Message);
|
||||
|
||||
status.RequestCancel();
|
||||
|
||||
Assert.True(status.IsCancelRequested);
|
||||
Assert.Equal("Starting 1 account.", status.Message); // no account to name yet
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheStatus_RefusesAMissingWorkspaceOrResult()
|
||||
{
|
||||
var status = new SyncAllStatus();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => status.Enter(1, null!));
|
||||
Assert.Throws<ArgumentNullException>(() => status.End(null!));
|
||||
}
|
||||
}
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,41 +454,165 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- active strip
|
||||
// ---------------------------------------------------------------- the table's default order (#168)
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds()
|
||||
/// <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));
|
||||
|
||||
|
||||
|
||||
/// <summary>A clone per repository that waits until the test lets that repository go (or fails it).</summary>
|
||||
private Dictionary<string, TaskCompletionSource> GateEachClone(params string[] names)
|
||||
{
|
||||
var gateAlpha = new TaskCompletionSource();
|
||||
var gateBravo = new TaskCompletionSource();
|
||||
Dictionary<string, TaskCompletionSource> gates = names.ToDictionary(n => n, _ => new TaskCompletionSource(), StringComparer.Ordinal);
|
||||
_git.CloneHandler = async (url, path, token, onProgress, ct) =>
|
||||
{
|
||||
Task gate = string.Equals(Path.GetFileName(path), "alpha", StringComparison.Ordinal) ? gateAlpha.Task : gateBravo.Task;
|
||||
// CancellationToken.None: the gates must not be cancelable — the timeout is the safety valve.
|
||||
await gate.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||
await gates[Path.GetFileName(path)].Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||
};
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); // concurrency 1
|
||||
return gates;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task WhileASyncRuns_TheRowInFlightIsOnTop_ThenTheFinishedOnes_TheLatestFirst_ThenTheOnesWaiting()
|
||||
{
|
||||
Dictionary<string, TaskCompletionSource> gates = GateEachClone("alpha", "bravo", "charlie", "delta");
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); // concurrency 1
|
||||
var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal);
|
||||
Assert.Empty(vm.ActiveRepos);
|
||||
|
||||
Task run = vm.SyncCommand.ExecuteAsync(null);
|
||||
await WaitUntilAsync(
|
||||
() => byName["alpha"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1,
|
||||
"alpha to enter the active strip");
|
||||
Assert.Equal("alpha", Assert.Single(vm.ActiveRepos).Name); // bounded by parallelism (1)
|
||||
await WaitUntilAsync(() => byName["alpha"].Status == SyncStatus.Cloning, "alpha to start");
|
||||
await TableReadsAsync(vm, "alpha", "bravo", "charlie", "delta");
|
||||
|
||||
gateAlpha.SetResult();
|
||||
await WaitUntilAsync(
|
||||
() => byName["bravo"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1,
|
||||
"alpha to leave and bravo to enter the active strip");
|
||||
Assert.Equal("bravo", Assert.Single(vm.ActiveRepos).Name); // alpha was removed on Done
|
||||
gates["alpha"].SetResult();
|
||||
await WaitUntilAsync(() => byName["bravo"].Status == SyncStatus.Cloning, "bravo to start");
|
||||
await TableReadsAsync(vm, "bravo", "alpha", "charlie", "delta"); // in flight, finished, waiting
|
||||
|
||||
gateBravo.SetResult();
|
||||
gates["bravo"].SetResult();
|
||||
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
|
||||
await TableReadsAsync(vm, "charlie", "bravo", "alpha", "delta"); // bravo finished after alpha: it is above it
|
||||
|
||||
gates["charlie"].SetResult();
|
||||
gates["delta"].SetResult();
|
||||
await run;
|
||||
|
||||
Assert.Empty(vm.ActiveRepos); // cleared when the run ends
|
||||
// The run is over: by name again, and every row is in the table exactly once.
|
||||
await TableReadsAsync(vm, "alpha", "bravo", "charlie", "delta");
|
||||
Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AFailedRow_IsBelowTheOnesInFlight_WhileTheSyncRuns_AndFirstWhenItIsOver()
|
||||
{
|
||||
Dictionary<string, TaskCompletionSource> gates = GateEachClone("alpha", "bravo", "charlie");
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); // concurrency 1
|
||||
var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal);
|
||||
|
||||
Task run = vm.SyncCommand.ExecuteAsync(null);
|
||||
await WaitUntilAsync(() => byName["alpha"].Status == SyncStatus.Cloning, "alpha to start");
|
||||
gates["alpha"].SetResult();
|
||||
await WaitUntilAsync(() => byName["bravo"].Status == SyncStatus.Cloning, "bravo to start");
|
||||
gates["bravo"].SetException(new InvalidOperationException("boom"));
|
||||
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
|
||||
|
||||
await TableReadsAsync(vm, "charlie", "bravo", "alpha"); // in flight, failed, finished
|
||||
|
||||
gates["charlie"].SetResult();
|
||||
await run;
|
||||
|
||||
await TableReadsAsync(vm, "bravo", "alpha", "charlie"); // the failed one first, then by name
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TheDefaultOrder_IsByName_WhateverOrderTheRepositoriesWereListedIn()
|
||||
{
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("Alpha"), Repo("bravo"));
|
||||
|
||||
await TableReadsAsync(vm, "Alpha", "bravo", "charlie");
|
||||
Assert.Null(vm.SortColumn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AColumnTheUserChose_Wins_AndAThirdClickOnIt_ReturnsToTheDefaultOrder()
|
||||
{
|
||||
Dictionary<string, TaskCompletionSource> gates = GateEachClone("alpha", "bravo", "charlie");
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); // concurrency 1
|
||||
var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal);
|
||||
|
||||
vm.SortCommand.Execute("Name");
|
||||
vm.SortCommand.Execute("Name");
|
||||
Assert.Equal("Name", vm.SortColumn);
|
||||
Assert.True(vm.SortDescending);
|
||||
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.
|
||||
Task run = vm.SyncCommand.ExecuteAsync(null);
|
||||
await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start");
|
||||
gates["charlie"].SetResult();
|
||||
await WaitUntilAsync(() => byName["bravo"].Status == SyncStatus.Cloning, "bravo to start");
|
||||
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);
|
||||
await TableReadsAsync(vm, "bravo", "charlie", "alpha");
|
||||
|
||||
gates["bravo"].SetResult();
|
||||
gates["alpha"].SetResult();
|
||||
await run;
|
||||
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AFilter_KeepsTheDefaultOrder_AndARowThatLeavesItAndReturns_ComesBackInItsPlace()
|
||||
{
|
||||
Dictionary<string, TaskCompletionSource> gates = GateEachClone("alpha", "bravo", "charlie");
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); // concurrency 1
|
||||
var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal);
|
||||
|
||||
vm.Filter = RepoFilter.Pending;
|
||||
Task run = vm.SyncCommand.ExecuteAsync(null);
|
||||
await WaitUntilAsync(() => byName["alpha"].Status == SyncStatus.Cloning, "alpha to start");
|
||||
await TableReadsAsync(vm, "bravo", "charlie"); // alpha is no longer waiting
|
||||
|
||||
vm.Filter = RepoFilter.All;
|
||||
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
|
||||
|
||||
gates["alpha"].SetResult();
|
||||
gates["bravo"].SetResult();
|
||||
gates["charlie"].SetResult();
|
||||
await run;
|
||||
await TableReadsAsync(vm, "alpha", "bravo", "charlie");
|
||||
}
|
||||
|
||||
|
||||
@@ -1364,8 +1488,9 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
vm.Filter = RepoFilter.Pending;
|
||||
Assert.Equal(["delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
|
||||
// Everything again, in the default order: in flight, failed, then the rest (#168).
|
||||
vm.Filter = RepoFilter.All;
|
||||
Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
Assert.Equal(["bravo", "charlie", "alpha", "delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1408,8 +1533,12 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
actions.Clear();
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
|
||||
// Each failure was inserted as it happened. Only when the run is over does the
|
||||
// table change its order as a whole (the latest first becomes by name, #168).
|
||||
Assert.Equal(["alpha", "delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
|
||||
Assert.Equal(
|
||||
[System.Collections.Specialized.NotifyCollectionChangedAction.Add, System.Collections.Specialized.NotifyCollectionChangedAction.Add],
|
||||
actions.Take(2));
|
||||
}
|
||||
|
||||
|
||||
@@ -2813,4 +2942,127 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
Assert.Equal("There is no token: enter one in the token box.", vm.ResultMessage);
|
||||
Assert.Empty(_git.CloneCalls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- progress in batches (#167)
|
||||
|
||||
[Fact]
|
||||
public void ByDefault_ProgressReachesTheWindowInBatches()
|
||||
{
|
||||
IProgress<RepoProgress> progress = WorkspaceViewModel.DefaultProgress(_ => { });
|
||||
|
||||
using BatchedRepoProgress batched = Assert.IsType<BatchedRepoProgress>(progress);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task WithBatchedProgress_EveryRowIsFinal_WhenTheRunIsOver_WithoutWaitingForTheNextBatch()
|
||||
{
|
||||
// A batch that would only be delivered in an hour, to a window that never gets
|
||||
// to it: whatever the rows show at the end, the end of the run put there.
|
||||
var time = new ManualTime();
|
||||
var window = new PumpedContext();
|
||||
_lister.Repositories = [Repo("alpha"), Repo("bravo"), Repo("charlie")];
|
||||
_git.CloneHandler = (_, path, _, _, _) =>
|
||||
string.Equals(Path.GetFileName(path), "bravo", StringComparison.Ordinal)
|
||||
? Task.FromException(new InvalidOperationException("boom"))
|
||||
: Task.CompletedTask;
|
||||
WorkspaceViewModel vm = Track(new WorkspaceViewModel(
|
||||
_lister, _git, _orgs,
|
||||
handler => new BatchedRepoProgress(handler, window, TimeSpan.FromHours(1), time),
|
||||
TimeSpan.FromMilliseconds(1), new NullActivityLog(), wsl: _wsl));
|
||||
vm.Organization = "acme";
|
||||
vm.Token = "token-1234567890";
|
||||
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
|
||||
vm.TargetFolder = NewTempFolder();
|
||||
await vm.LoadReposCommand.ExecuteAsync(null);
|
||||
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(0, window.Waiting);
|
||||
Assert.True(Assert.Single(time.Timers).IsDisposed);
|
||||
Assert.Equal(
|
||||
[("bravo", SyncStatus.Failed), ("alpha", SyncStatus.Done), ("charlie", SyncStatus.Done)],
|
||||
vm.FilteredRepos.Select(r => (r.Name, r.Status)));
|
||||
Assert.Equal(3, vm.CompletedCount);
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Hands a sync's progress reports to the window a few times a second instead of one
|
||||
/// by one. Reports come from the engine's worker threads, thousands per run (a clone
|
||||
/// reports every whole percent), and each one delivered on its own costs the window's
|
||||
/// thread a row update and a layout pass: that is what stopped the window answering
|
||||
/// during a large run (#167). Reports wait here for at most the interval, are then
|
||||
/// delivered together on the thread this instance was created on, and of several for
|
||||
/// one repository only the latest is delivered. Every report carries the repository's
|
||||
/// whole state, so nothing is lost with the ones it replaced.
|
||||
/// </summary>
|
||||
public sealed class BatchedRepoProgress : IProgress<RepoProgress>, IDisposable
|
||||
{
|
||||
/// <summary>How long a report waits at most before it is delivered.</summary>
|
||||
public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly Action<RepoProgress> _handler;
|
||||
|
||||
private readonly SynchronizationContext _context;
|
||||
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
private readonly ITimer _timer;
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
/// <summary>Reports not delivered yet, oldest first. Guarded by <see cref="_gate"/>.</summary>
|
||||
private List<RepoProgress> _pending = [];
|
||||
|
||||
/// <summary>True while a delivery is on its way. Guarded by <see cref="_gate"/>.</summary>
|
||||
private bool _deliveryScheduled;
|
||||
|
||||
/// <summary>True once the timer is gone. Guarded by <see cref="_gate"/>.</summary>
|
||||
private bool _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the progress; call it on the thread the reports are to be delivered on.</summary>
|
||||
/// <param name="handler">Receives each delivered report, on the delivery thread.</param>
|
||||
/// <param name="context">Where to deliver; the calling thread's context by default.</param>
|
||||
/// <param name="interval">How long a report waits at most; <see cref="DefaultInterval"/> by default.</param>
|
||||
/// <param name="timeProvider">The timer source; the system's by default.</param>
|
||||
public BatchedRepoProgress(
|
||||
Action<RepoProgress> handler,
|
||||
SynchronizationContext? context = null,
|
||||
TimeSpan? interval = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
_handler = handler;
|
||||
_context = context ?? SynchronizationContext.Current ?? new SynchronizationContext();
|
||||
_interval = interval ?? DefaultInterval;
|
||||
_timer = (timeProvider ?? TimeProvider.System).CreateTimer(
|
||||
_ => PostDelivery(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Takes a report from any thread; it is delivered with the next batch.</summary>
|
||||
/// <param name="value">The report.</param>
|
||||
public void Report(RepoProgress value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
bool timerIsGone;
|
||||
lock(_gate)
|
||||
{
|
||||
_pending.Add(value);
|
||||
if(_deliveryScheduled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_deliveryScheduled = true;
|
||||
timerIsGone = _disposed;
|
||||
if(!timerIsGone)
|
||||
{
|
||||
_timer.Change(_interval, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
if(timerIsGone)
|
||||
{
|
||||
// After the run (an abandoned operation that still finished): nothing is
|
||||
// left to batch it with.
|
||||
PostDelivery();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Delivers what is still waiting, now, on the calling thread, and stops the timer.
|
||||
/// Call it on the thread this instance was created on when the run has ended, so
|
||||
/// the rows are final before the outcome is stated. A report that still arrives
|
||||
/// afterwards is delivered on its own.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
_timer.Dispose();
|
||||
Deliver();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Of several reports for one repository, the latest; repositories stay in the
|
||||
/// order in which each was first reported.
|
||||
/// </summary>
|
||||
internal static List<RepoProgress> LatestPerRepository(List<RepoProgress> reports)
|
||||
{
|
||||
var latest = new List<RepoProgress>(reports.Count);
|
||||
var slots = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach(RepoProgress report in reports)
|
||||
{
|
||||
if(slots.TryGetValue(report.RepoName, out int slot))
|
||||
{
|
||||
latest[slot] = report;
|
||||
}
|
||||
else
|
||||
{
|
||||
slots[report.RepoName] = latest.Count;
|
||||
latest.Add(report);
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void PostDelivery() => _context.Post(_ => Deliver(), null);
|
||||
|
||||
|
||||
|
||||
private void Deliver()
|
||||
{
|
||||
List<RepoProgress> batch;
|
||||
lock(_gate)
|
||||
{
|
||||
batch = _pending;
|
||||
_pending = [];
|
||||
_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))
|
||||
{
|
||||
try
|
||||
{
|
||||
_handler(report);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
failure ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex);
|
||||
}
|
||||
}
|
||||
failure?.Throw();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ public sealed partial class RepoItemViewModel : ObservableObject
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When the row last started or finished an operation, as a count that grows with
|
||||
/// every such change in its workspace (0 = never). The table's default order uses
|
||||
/// it: of two finished rows, the one that finished later comes first (#168).
|
||||
/// </summary>
|
||||
internal long LastActivity { get; set; }
|
||||
|
||||
/// <summary>The repository this row represents; never changes after construction.</summary>
|
||||
public RepoDescriptor Descriptor { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says in the activity log when the window stopped responding, for how long, and
|
||||
/// what was running (#167). It asks the window's thread to answer a few times a
|
||||
/// second and measures how long each answer takes: a late answer is time in which a
|
||||
/// click or a repaint waited just as long. A window that stays unresponsive is
|
||||
/// reported once while it still is, so a hang that never ends leaves a line too.
|
||||
/// After one report, further stalls in the next <see cref="QuietPeriod"/> are counted
|
||||
/// and reported together, so a busy run cannot fill the log.
|
||||
/// </summary>
|
||||
public sealed class ResponsivenessMonitor : IDisposable
|
||||
{
|
||||
/// <summary>How often the window's thread is asked to answer.</summary>
|
||||
public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>An answer that takes at least this long is reported.</summary>
|
||||
public static readonly TimeSpan DefaultThreshold = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>A window that has not answered for this long is reported while it still has not.</summary>
|
||||
public static readonly TimeSpan StillWaitingAfter = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <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;
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
private readonly TimeSpan _threshold;
|
||||
|
||||
private readonly ITimer _timer;
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
/// <summary>When the question in flight was asked.</summary>
|
||||
private long _askedAt;
|
||||
|
||||
/// <summary>When it was answered; valid once <see cref="_answered"/> is true.</summary>
|
||||
private long _answeredAt;
|
||||
|
||||
private volatile bool _answered;
|
||||
|
||||
/// <summary>True while a question is in flight.</summary>
|
||||
private bool _asked;
|
||||
|
||||
private bool _saidStillWaiting;
|
||||
|
||||
private bool _stopped;
|
||||
|
||||
/// <summary>When the last stall got its own line; stalls are collected from then on. Null when the next one gets a line.</summary>
|
||||
private long? _quietSince;
|
||||
|
||||
private int _collected;
|
||||
|
||||
private TimeSpan _collectedTotal;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
|
||||
/// <summary>Starts watching.</summary>
|
||||
/// <param name="post">
|
||||
/// Queues an action on the window's thread; false when that thread no longer takes
|
||||
/// work (the window is closing), which ends the watch.
|
||||
/// </param>
|
||||
/// <param name="log">Where stalls are reported.</param>
|
||||
/// <param name="timeProvider">Clock and timer source; the system's by default.</param>
|
||||
/// <param name="interval">How often to ask; <see cref="DefaultInterval"/> by default.</param>
|
||||
/// <param name="threshold">The shortest stall worth a line; <see cref="DefaultThreshold"/> by default.</param>
|
||||
public ResponsivenessMonitor(
|
||||
Func<Action, bool> post,
|
||||
IActivityLog log,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? interval = null,
|
||||
TimeSpan? threshold = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(post);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
_post = post;
|
||||
_log = log;
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
_threshold = threshold ?? DefaultThreshold;
|
||||
TimeSpan period = interval ?? DefaultInterval;
|
||||
_timer = _time.CreateTimer(_ => Check(), null, period, period);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the app is doing, in words that follow "while" (for example "syncing
|
||||
/// 'Engineering'"); named in each report. Null or empty when nothing is running.
|
||||
/// </summary>
|
||||
public string? Activity
|
||||
{
|
||||
get => _activity;
|
||||
set => _activity = value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stops watching; stalls collected but not yet reported are reported now.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
if(_stopped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_stopped = true;
|
||||
ReportCollected();
|
||||
}
|
||||
_timer.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One look, on the timer's thread: was the last question answered, how late, and
|
||||
/// if so ask the next one.
|
||||
/// </summary>
|
||||
internal void Check()
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
if(_stopped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(_quietSince is long since && _time.GetElapsedTime(since) >= QuietPeriod)
|
||||
{
|
||||
ReportCollected();
|
||||
_quietSince = null;
|
||||
}
|
||||
|
||||
bool wasPaused = _lookedAt is long last && _time.GetElapsedTime(last) >= PauseAfter;
|
||||
_lookedAt = _time.GetTimestamp();
|
||||
if(wasPaused)
|
||||
{
|
||||
_asked = false;
|
||||
}
|
||||
|
||||
if(_asked)
|
||||
{
|
||||
if(!_answered)
|
||||
{
|
||||
TimeSpan soFar = _time.GetElapsedTime(_askedAt);
|
||||
if(soFar >= StillWaitingAfter && !_saidStillWaiting)
|
||||
{
|
||||
_saidStillWaiting = true;
|
||||
_log.Error($"The window has not responded for {Milliseconds(soFar)} ms{WhileWhat(_activity)} and still does not.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan waited = _time.GetElapsedTime(_askedAt, _answeredAt);
|
||||
if(waited >= _threshold)
|
||||
{
|
||||
Note(waited);
|
||||
}
|
||||
}
|
||||
|
||||
Ask();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void Ask()
|
||||
{
|
||||
_askedAt = _time.GetTimestamp();
|
||||
_answered = false;
|
||||
_saidStillWaiting = false;
|
||||
_asked = _post(Answer);
|
||||
if(!_asked)
|
||||
{
|
||||
// The window's thread takes no more work: there is nothing left to watch.
|
||||
_stopped = true;
|
||||
ReportCollected();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs on the window's thread: nothing but a timestamp, no file is touched there.</summary>
|
||||
private void Answer()
|
||||
{
|
||||
_answeredAt = _time.GetTimestamp();
|
||||
_answered = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The first stall after a quiet time gets its own line; the ones that follow closely are collected.</summary>
|
||||
private void Note(TimeSpan waited)
|
||||
{
|
||||
if(_quietSince is null)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_collectedLongest = waited;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void ReportCollected()
|
||||
{
|
||||
if(_collected == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string times = _collected == 1 ? "once more" : $"{_collected.ToString(CultureInfo.InvariantCulture)} more times";
|
||||
_log.Info(
|
||||
$"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;
|
||||
_collectedLongest = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static string WhileWhat(string? activity) => activity is { Length: > 0 } ? $" while {activity}" : "";
|
||||
|
||||
|
||||
|
||||
private static string Milliseconds(TimeSpan span)
|
||||
=> ((long)span.TotalMilliseconds).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -29,6 +29,12 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
/// </summary>
|
||||
public Action<Guid, SyncAllAccountState>? AccountStateChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Told where the pass stands, from its start to its end, so a status bar can show
|
||||
/// it whichever workspace is on screen (#167). Updated on the caller's context.
|
||||
/// </summary>
|
||||
public SyncAllStatus? Status { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +49,27 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(accountWorkspaces);
|
||||
|
||||
Status?.Begin(accountWorkspaces.Count);
|
||||
try
|
||||
{
|
||||
SyncAllResult result = await RunQueueAsync(accountWorkspaces, ct);
|
||||
Status?.End(result);
|
||||
return result;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
// Not expected (every account's trouble is a skip, not a throw), but a bar
|
||||
// that says 'running' for ever would be worse than the error itself.
|
||||
Status?.Stop(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<SyncAllResult> RunQueueAsync(
|
||||
IReadOnlyList<WorkspaceViewModel> accountWorkspaces, CancellationToken ct)
|
||||
{
|
||||
foreach(WorkspaceViewModel workspace in accountWorkspaces)
|
||||
{
|
||||
Announce(workspace, SyncAllAccountState.Queued);
|
||||
@@ -52,6 +79,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
|
||||
int ran = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
for(int i = 0; i < accountWorkspaces.Count; i++)
|
||||
{
|
||||
@@ -67,7 +95,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
Announce(accountWorkspaces[rest], SyncAllAccountState.Skipped);
|
||||
}
|
||||
_log.Info("Sync all: canceled — remaining accounts were not started.");
|
||||
return new SyncAllResult(ran, skipped, WasCanceled: true);
|
||||
return new SyncAllResult(ran, skipped, WasCanceled: true, failed);
|
||||
}
|
||||
|
||||
if(workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths)
|
||||
@@ -92,6 +120,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
}
|
||||
|
||||
Announce(workspace, SyncAllAccountState.Running);
|
||||
Status?.Enter(i + 1, workspace);
|
||||
|
||||
if(!workspace.HasLoadedRepos)
|
||||
{
|
||||
@@ -123,19 +152,24 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
// used, its token went away at that moment): that is not a success with
|
||||
// nothing to report.
|
||||
skipped++;
|
||||
failed++;
|
||||
Announce(workspace, SyncAllAccountState.Failed);
|
||||
_log.Info($"Sync all: '{workspace.DisplayName}' could not be synced — {workspace.ResultMessage}");
|
||||
continue;
|
||||
}
|
||||
|
||||
ran++;
|
||||
if(workspace.HasFailedRepos)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
Announce(
|
||||
workspace,
|
||||
workspace.HasFailedRepos ? SyncAllAccountState.Failed : SyncAllAccountState.Succeeded);
|
||||
_log.Info($"Sync all: finished '{workspace.DisplayName}'.");
|
||||
}
|
||||
|
||||
return new SyncAllResult(ran, skipped, WasCanceled: false);
|
||||
return new SyncAllResult(ran, skipped, WasCanceled: false, failed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>What a sync-all pass is doing, or how the last one ended; see <see cref="SyncAllStatus"/>.</summary>
|
||||
public enum SyncAllOutcome
|
||||
{
|
||||
/// <summary>No pass has run yet.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>A pass is running.</summary>
|
||||
Running,
|
||||
|
||||
/// <summary>Every account that ran finished without a failed repository.</summary>
|
||||
Finished,
|
||||
|
||||
/// <summary>The pass finished, and at least one account had failures or could not be synced.</summary>
|
||||
FinishedWithFailures,
|
||||
|
||||
/// <summary>The pass was canceled before every account had its turn.</summary>
|
||||
Canceled,
|
||||
|
||||
/// <summary>The pass stopped on an unexpected error.</summary>
|
||||
Stopped,
|
||||
}
|
||||
@@ -12,4 +12,8 @@ namespace gclo.ViewModels;
|
||||
/// <param name="Ran">Accounts whose sync ran to completion (successfully or not).</param>
|
||||
/// <param name="Skipped">Accounts skipped because they were busy, could not load, or had nothing to sync.</param>
|
||||
/// <param name="WasCanceled">True when cancellation stopped the queue before every account was processed.</param>
|
||||
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled);
|
||||
/// <param name="Failed">
|
||||
/// Accounts that ended the pass marked as failed: their sync ran and a repository
|
||||
/// failed, or it could not run at all. The second kind is also in <paramref name="Skipped"/>.
|
||||
/// </param>
|
||||
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled, int Failed = 0);
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a sync-all pass is doing right now, in words a status bar can show for the
|
||||
/// whole pass: which account, which of how many, how far along, and afterwards how it
|
||||
/// ended (#167). <see cref="SyncAllCoordinator"/> drives it; the window binds to it,
|
||||
/// so a pass is visible whichever workspace is on screen. Used on the window's thread
|
||||
/// only.
|
||||
/// </summary>
|
||||
public sealed partial class SyncAllStatus : ObservableObject
|
||||
{
|
||||
/// <summary>The account in flight; its progress is followed while it is.</summary>
|
||||
private WorkspaceViewModel? _current;
|
||||
|
||||
private int _position;
|
||||
|
||||
private int _accounts;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates a status with nothing to show.</summary>
|
||||
public SyncAllStatus()
|
||||
{
|
||||
Title = "";
|
||||
Message = "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True from the start of a pass until its last account is done.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True while there is something to show: during a pass, and afterwards until the
|
||||
/// result is closed (which the view does by setting it to false).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsOpen { get; set; }
|
||||
|
||||
/// <summary>Running, or how the last pass ended.</summary>
|
||||
[ObservableProperty]
|
||||
public partial SyncAllOutcome Outcome { get; private set; }
|
||||
|
||||
/// <summary>The headline, for example "Sync all: account 2 of 3".</summary>
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; private set; }
|
||||
|
||||
/// <summary>The detail, for example "Engineering: 120 of 281 repositories.".</summary>
|
||||
[ObservableProperty]
|
||||
public partial string Message { get; private set; }
|
||||
|
||||
/// <summary>The account in flight, for a link to its workspace; null between accounts and after the pass.</summary>
|
||||
[ObservableProperty]
|
||||
public partial Guid? CurrentAccountId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How far the account in flight is, from 0 to 1; null while that cannot be said
|
||||
/// (its repositories are being listed, or nothing is in flight).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial double? Fraction { get; private set; }
|
||||
|
||||
/// <summary>True once the user asked for the pass to stop; the account in flight still finishes.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsCancelRequested { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>A pass over <paramref name="accounts"/> accounts starts.</summary>
|
||||
/// <param name="accounts">How many accounts are scheduled.</param>
|
||||
public void Begin(int accounts)
|
||||
{
|
||||
Release();
|
||||
_accounts = accounts;
|
||||
_position = 0;
|
||||
CurrentAccountId = null;
|
||||
Fraction = null;
|
||||
IsCancelRequested = false;
|
||||
Outcome = SyncAllOutcome.Running;
|
||||
Title = "Sync all";
|
||||
Message = $"Starting {Count(accounts, "account")}.";
|
||||
IsRunning = true;
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The pass reached an account; its progress is followed until the next call.</summary>
|
||||
/// <param name="position">Its place in the pass, from 1.</param>
|
||||
/// <param name="workspace">Its workspace.</param>
|
||||
public void Enter(int position, WorkspaceViewModel workspace)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workspace);
|
||||
Release();
|
||||
_position = position;
|
||||
_current = workspace;
|
||||
workspace.PropertyChanged += OnCurrentChanged;
|
||||
CurrentAccountId = workspace.AccountId;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The user asked for the pass to stop. Says so; the account in flight still finishes.</summary>
|
||||
public void RequestCancel()
|
||||
{
|
||||
if(!IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsCancelRequested = true;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The pass ended; the result stays until it is closed.</summary>
|
||||
/// <param name="result">How it ended.</param>
|
||||
public void End(SyncAllResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
Leave();
|
||||
Outcome = result.WasCanceled ? SyncAllOutcome.Canceled
|
||||
: result.Failed > 0 ? SyncAllOutcome.FinishedWithFailures
|
||||
: SyncAllOutcome.Finished;
|
||||
Title = result.WasCanceled ? "Sync all canceled" : "Sync all finished";
|
||||
Message = Describe(result);
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The pass stopped on an unexpected error.</summary>
|
||||
/// <param name="reason">What went wrong.</param>
|
||||
public void Stop(string reason)
|
||||
{
|
||||
Leave();
|
||||
Outcome = SyncAllOutcome.Stopped;
|
||||
Title = "Sync all stopped";
|
||||
Message = reason;
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The sentence that sums up a finished or canceled pass.</summary>
|
||||
internal static string Describe(SyncAllResult result)
|
||||
{
|
||||
string summary = $"{Count(result.Ran, "account")} synced, {result.Skipped.ToString(CultureInfo.InvariantCulture)} skipped.";
|
||||
return result.Failed switch
|
||||
{
|
||||
0 => summary,
|
||||
1 => summary + " 1 account needs attention; it is marked in the pane.",
|
||||
_ => summary + $" {result.Failed.ToString(CultureInfo.InvariantCulture)} accounts need attention; they are marked in the pane.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static string Count(int number, string noun)
|
||||
=> number == 1 ? $"1 {noun}" : $"{number.ToString(CultureInfo.InvariantCulture)} {noun}s";
|
||||
|
||||
|
||||
|
||||
private void OnCurrentChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if(e.PropertyName is nameof(WorkspaceViewModel.IsLoadingRepos)
|
||||
or nameof(WorkspaceViewModel.IsRunning)
|
||||
or nameof(WorkspaceViewModel.CompletedCount)
|
||||
or nameof(WorkspaceViewModel.TotalCount))
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if(_current is not { } workspace)
|
||||
{
|
||||
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 && workspace.CompletedCount <= workspace.TotalCount)
|
||||
{
|
||||
doing = $"{workspace.CompletedCount.ToString(CultureInfo.InvariantCulture)} of "
|
||||
+ (workspace.TotalCount == 1 ? "1 repository" : $"{workspace.TotalCount.ToString(CultureInfo.InvariantCulture)} repositories");
|
||||
Fraction = workspace.TotalCount > 0 ? (double)workspace.CompletedCount / workspace.TotalCount : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
doing = workspace.IsLoadingRepos ? "listing its repositories" : "starting";
|
||||
Fraction = null;
|
||||
}
|
||||
|
||||
Title = $"Sync all: account {_position.ToString(CultureInfo.InvariantCulture)} of {_accounts.ToString(CultureInfo.InvariantCulture)}";
|
||||
Message = $"{workspace.DisplayName}: {doing}."
|
||||
+ (IsCancelRequested ? " Stopping when this account is done." : "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Nothing is in flight any more.</summary>
|
||||
private void Leave()
|
||||
{
|
||||
Release();
|
||||
CurrentAccountId = null;
|
||||
Fraction = null;
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void Release()
|
||||
{
|
||||
if(_current is { } workspace)
|
||||
{
|
||||
workspace.PropertyChanged -= OnCurrentChanged;
|
||||
_current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
set;
|
||||
}
|
||||
|
||||
/// <summary>Counts the starts and finishes of rows; see <see cref="RepoItemViewModel.LastActivity"/>.</summary>
|
||||
private long _activityClock;
|
||||
|
||||
/// <summary>Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other.</summary>
|
||||
private bool _syncingSelection;
|
||||
|
||||
@@ -99,8 +102,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Production dependencies by default; pass fakes for testing. The default progress
|
||||
/// factory is <see cref="Progress{T}"/>, which marshals via the SynchronizationContext
|
||||
/// captured at construction (the UI thread in the app); tests inject a synchronous one.
|
||||
/// factory is <see cref="BatchedRepoProgress"/>, which delivers on the
|
||||
/// SynchronizationContext it is created on (the UI thread in the app), a few times a
|
||||
/// second rather than once per report; tests inject a synchronous one.
|
||||
/// A non-null <paramref name="account"/> seeds the workspace from that profile — its
|
||||
/// organization, target folder, subfolder preference, and concurrency — and pulls the
|
||||
/// token from <paramref name="tokenVault"/> (the Token setter's org lookup fires
|
||||
@@ -126,7 +130,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
_git = git ?? new LibGit2GitClient();
|
||||
_wsl = wsl ?? new WslCloner();
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
_progressFactory = progressFactory ?? (handler => new Progress<RepoProgress>(handler));
|
||||
_progressFactory = progressFactory ?? DefaultProgress;
|
||||
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
|
||||
_log = log ?? new FileActivityLog(GcloPaths.LogDirectory);
|
||||
_account = account;
|
||||
@@ -207,13 +211,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// </summary>
|
||||
public ObservableCollection<RepoItemViewModel> FilteredRepos { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Rows with a git operation in flight right now; feeds the pinned active strip.
|
||||
/// Maintained incrementally from progress reports, so its size is bounded by
|
||||
/// <see cref="MaxConcurrency"/>. Cleared when a run starts and again when it ends.
|
||||
/// </summary>
|
||||
public ObservableCollection<RepoItemViewModel> ActiveRepos { get; } = new();
|
||||
|
||||
/// <summary>Organizations discovered from the current token; feeds the org dropdown.</summary>
|
||||
public ObservableCollection<string> Organizations { get; } = new();
|
||||
|
||||
@@ -338,7 +335,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial bool HasFailedRepos { get; set; }
|
||||
|
||||
/// <summary>Column the table is currently sorted by, or null for load order.</summary>
|
||||
/// <summary>
|
||||
/// Column the table is sorted by, or null for the default order: while a sync
|
||||
/// runs, the repositories in flight, then the failed ones, then the finished ones
|
||||
/// (the latest first), then the ones still waiting; otherwise the failed ones
|
||||
/// first, then by name (#168).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial string? SortColumn { get; set; }
|
||||
|
||||
@@ -658,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()
|
||||
{
|
||||
@@ -996,7 +1010,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
IsRunning = true;
|
||||
ResetRunResult();
|
||||
ActiveRepos.Clear();
|
||||
try
|
||||
{
|
||||
// First, before any row is touched: a sync that cannot start for want of a
|
||||
@@ -1022,11 +1035,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
StatusText = $"Syncing {selected.Count} repositories...";
|
||||
_log.Info($"Sync started: {selected.Count} repositories into '{targetRoot}'.");
|
||||
|
||||
// Constructed on the UI thread: Progress<T> captures the WinUI
|
||||
// SynchronizationContext, so HandleProgress always runs on the UI thread.
|
||||
// Failure logging is layered in front of it so the per-entry file I/O
|
||||
// happens on the engine's worker thread, never on the dispatcher (#30).
|
||||
var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log);
|
||||
// Constructed on the UI thread: the delivery captures the WinUI
|
||||
// SynchronizationContext, so HandleProgress always runs on the UI thread,
|
||||
// a few times a second with each repository's latest state rather than
|
||||
// once per report (#167). Failure logging is layered in front of it so the
|
||||
// per-entry file I/O happens on the engine's worker thread, never on the
|
||||
// dispatcher (#30).
|
||||
IProgress<RepoProgress> delivery = _progressFactory(HandleProgress);
|
||||
var progress = new FailureLoggingProgress(delivery, _log);
|
||||
|
||||
var request = new SyncRequest(
|
||||
Organization.Trim(), token, targetRoot, MaxConcurrency);
|
||||
@@ -1034,7 +1050,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
Func<SyncRequest, IReadOnlyList<RepoDescriptor>, IProgress<RepoProgress>, CancellationToken, Task<SyncSummary>> run =
|
||||
SyncRunner ?? ((req, descs, prog, ct) => new OrgSyncEngine(_lister, _git).SyncAsync(req, descs, prog, ct));
|
||||
SyncSummary summary = await run(request, descriptors, progress, cancellationToken);
|
||||
SyncSummary summary;
|
||||
try
|
||||
{
|
||||
summary = await run(request, descriptors, progress, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Whatever still waits to be delivered reaches the rows now, before the
|
||||
// outcome is stated.
|
||||
(delivery as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
string summaryText = (summary.WasCanceled ? "Canceled" : "Finished")
|
||||
+ $": {summary.Cloned} cloned, {summary.Updated} updated, "
|
||||
@@ -1080,7 +1106,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
HasCompletedRun = true;
|
||||
IsRunning = false;
|
||||
ActiveRepos.Clear();
|
||||
RebuildFilteredRepos();
|
||||
ResultOpen = true;
|
||||
}
|
||||
@@ -1088,6 +1113,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>How progress reaches the window when nothing else was asked for: in batches (#167).</summary>
|
||||
internal static IProgress<RepoProgress> DefaultProgress(Action<RepoProgress> handler)
|
||||
=> new BatchedRepoProgress(handler);
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- retry
|
||||
|
||||
private bool CanRetryFailed() =>
|
||||
@@ -1211,7 +1242,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
IsResolvingPaths = false;
|
||||
}
|
||||
RecomputeCompletedCount();
|
||||
UpdateFilteredMembership(item);
|
||||
PlaceInTable(item);
|
||||
RetryFailedCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -1225,17 +1256,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
_log.Info($"{item.Name}: applying path recovery "
|
||||
+ $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped).");
|
||||
// Pulling shows the row's indeterminate bar while the checkout runs.
|
||||
item.Status = SyncStatus.Pulling;
|
||||
SetStatus(item, SyncStatus.Pulling);
|
||||
await _git.ApplyRecoveryAsync(path, recovery, _lifetimeCts.Token);
|
||||
|
||||
item.Status = SyncStatus.Done;
|
||||
SetStatus(item, SyncStatus.Done);
|
||||
item.Error = null;
|
||||
item.InvalidPaths = null;
|
||||
_log.Info($"{item.Name}: path recovery applied; repository checked out.");
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
item.Status = SyncStatus.Failed;
|
||||
SetStatus(item, SyncStatus.Failed);
|
||||
item.Error = ex.Message;
|
||||
// A still-invalid or colliding mapping keeps the row resolvable with the
|
||||
// fresh path list; any other failure clears the payload (renaming again
|
||||
@@ -1260,13 +1291,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
try
|
||||
{
|
||||
_log.Info($"{item.Name}: cloning in WSL instead.");
|
||||
item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here
|
||||
SetStatus(item, SyncStatus.Pulling); // indeterminate bar; git in WSL reports no percentage here
|
||||
// The organization the table was loaded for, not whatever the box says now:
|
||||
// the row belongs to that organization, and its folder under ~/gclo should too.
|
||||
WslCloneResult result = await _wsl.CloneAsync(
|
||||
item.Descriptor.CloneUrl, LoadedOrganization ?? Organization.Trim(), item.Name, TokenToUseNow(), _lifetimeCts.Token);
|
||||
|
||||
item.Status = SyncStatus.Done;
|
||||
SetStatus(item, SyncStatus.Done);
|
||||
item.Error = null;
|
||||
item.InvalidPaths = null;
|
||||
item.Note = $"Cloned in WSL ({result.Distribution}): {result.WindowsPath}";
|
||||
@@ -1274,7 +1305,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
item.Status = SyncStatus.Failed;
|
||||
SetStatus(item, SyncStatus.Failed);
|
||||
item.Error = ex.Message;
|
||||
_log.Error($"{item.Name}: clone in WSL failed: {ex.Message}", ex);
|
||||
}
|
||||
@@ -1286,7 +1317,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the table by a column ("Name", "Status", "Branch", or "Archived"); a second
|
||||
/// click on the same column flips the direction. The re-order is stable and in place.
|
||||
/// click on the same column flips the direction, and a third puts the table back
|
||||
/// in its default order (see <see cref="SortColumn"/>). The re-order is stable.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void Sort(string column)
|
||||
@@ -1298,7 +1330,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
if(string.Equals(SortColumn, column, StringComparison.Ordinal))
|
||||
{
|
||||
SortDescending = !SortDescending;
|
||||
if(SortDescending)
|
||||
{
|
||||
SortColumn = null;
|
||||
SortDescending = false;
|
||||
RebuildFilteredRepos();
|
||||
return;
|
||||
}
|
||||
SortDescending = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1365,22 +1404,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
item.InvalidPaths = report.InvalidPaths;
|
||||
}
|
||||
|
||||
// Active-strip membership is maintained incrementally: a full rescan per report
|
||||
// would cost O(rows) on every progress tick. Contains/Remove stay cheap because
|
||||
// the collection is bounded by MaxConcurrency.
|
||||
bool isActive = item.Status is SyncStatus.Cloning or SyncStatus.Pulling;
|
||||
if(isActive)
|
||||
{
|
||||
if(!ActiveRepos.Contains(item))
|
||||
{
|
||||
ActiveRepos.Add(item);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ActiveRepos.Remove(item);
|
||||
}
|
||||
|
||||
if(report.Status == SyncStatus.Failed)
|
||||
{
|
||||
// Logged already by FailureLoggingProgress on the worker thread. Failures
|
||||
@@ -1397,10 +1420,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
if(statusBefore != item.Status)
|
||||
{
|
||||
// Status-dependent filters follow every transition (a row entering Cloning
|
||||
// leaves Pending and joins Active immediately). Only this one row can have
|
||||
// changed membership, so it is inserted or removed in place — no O(n)
|
||||
// rebuild, no Reset notification, no scroll jump (#30).
|
||||
UpdateFilteredMembership(item);
|
||||
// leaves Pending and joins Active immediately), and a row in flight sits
|
||||
// at the top of the table (#168). Only this one row can have changed its
|
||||
// membership or its place, so it alone is inserted, removed, or moved — no
|
||||
// O(n) rebuild, no Reset notification, no scroll jump (#30).
|
||||
item.LastActivity = ++_activityClock;
|
||||
PlaceInTable(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1471,12 +1496,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// table's current sort order, then re-derives the header checkbox from the
|
||||
/// visible rows. Used when the filter, the table contents, or the sort order
|
||||
/// change; single-row status transitions go through
|
||||
/// <see cref="UpdateFilteredMembership"/> instead. A no-op when the visible set
|
||||
/// <see cref="PlaceInTable"/> instead. A no-op when the visible set
|
||||
/// is already correct, so list controls keep their scroll position.
|
||||
/// </summary>
|
||||
private void RebuildFilteredRepos()
|
||||
{
|
||||
var desired = Repos.Where(MatchesFilter).ToList();
|
||||
desired.Sort(CompareForTable);
|
||||
if(!desired.SequenceEqual(FilteredRepos))
|
||||
{
|
||||
FilteredRepos.Clear();
|
||||
@@ -1492,50 +1518,140 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>True while a git operation is running for the row.</summary>
|
||||
private static bool IsInFlight(RepoItemViewModel repo)
|
||||
=> repo.Status is SyncStatus.Cloning or SyncStatus.Pulling;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies one row's current filter match to <see cref="FilteredRepos"/>: inserts it
|
||||
/// at its table position (binary search on <see cref="_reposIndex"/>) when it became
|
||||
/// visible, removes it when it became hidden, and does nothing when its membership
|
||||
/// did not change. O(log n) instead of the former O(n) rebuild per progress report.
|
||||
/// Which group a row belongs to in the default order: the ones in flight (0), the
|
||||
/// failed ones (1), while a sync runs the finished ones (2), and the rest (3).
|
||||
/// </summary>
|
||||
private void UpdateFilteredMembership(RepoItemViewModel item)
|
||||
private int GroupOf(RepoItemViewModel repo)
|
||||
{
|
||||
if(IsInFlight(repo))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if(repo.Status == SyncStatus.Failed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return IsRunning && repo.Status is SyncStatus.Done or SyncStatus.Canceled ? 2 : 3;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The table's order, as a comparison (negative: <paramref name="a"/> comes first).
|
||||
/// With a column chosen by the user it is that column's order, which
|
||||
/// <see cref="Repos"/> holds. Otherwise it is the default order (#168): what is
|
||||
/// happening now is on top and the list moves as the sync goes. While a sync runs:
|
||||
/// the rows in flight (the one that started first on top), the failed ones and
|
||||
/// then the finished ones (the latest first in each), then the ones still waiting,
|
||||
/// by name. With no sync running: the failed ones, then everything else, by name.
|
||||
/// Never 0 for two different rows, so every row has exactly one place.
|
||||
/// </summary>
|
||||
private int CompareForTable(RepoItemViewModel a, RepoItemViewModel b)
|
||||
{
|
||||
if(SortColumn is null)
|
||||
{
|
||||
int groupA = GroupOf(a);
|
||||
int groupB = GroupOf(b);
|
||||
if(groupA != groupB)
|
||||
{
|
||||
return groupA.CompareTo(groupB);
|
||||
}
|
||||
|
||||
int byActivity = groupA switch
|
||||
{
|
||||
0 => a.LastActivity.CompareTo(b.LastActivity),
|
||||
1 or 2 when IsRunning => b.LastActivity.CompareTo(a.LastActivity),
|
||||
_ => 0,
|
||||
};
|
||||
if(byActivity != 0)
|
||||
{
|
||||
return byActivity;
|
||||
}
|
||||
|
||||
// A table holds one row per name, whatever its case (the load sees to it).
|
||||
return StringComparer.OrdinalIgnoreCase.Compare(a.Name, b.Name);
|
||||
}
|
||||
return _reposIndex[a].CompareTo(_reposIndex[b]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sets a row's status outside a sync run (a recovery) and puts the row where it now belongs.</summary>
|
||||
private void SetStatus(RepoItemViewModel item, SyncStatus status)
|
||||
{
|
||||
item.Status = status;
|
||||
item.LastActivity = ++_activityClock;
|
||||
PlaceInTable(item);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Brings one row's presence and place in <see cref="FilteredRepos"/> in line with
|
||||
/// its current state: inserted where it belongs when it now matches the filter,
|
||||
/// removed when it no longer does, moved when its place changed (it started,
|
||||
/// finished, or failed), and left alone otherwise. One row, not the former O(n)
|
||||
/// rebuild per progress report.
|
||||
/// </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);
|
||||
bool wasVisible = _filteredSet.Contains(item);
|
||||
if(visible == wasVisible)
|
||||
int current = _filteredSet.Contains(item) ? FilteredRepos.IndexOf(item) : -1;
|
||||
if(!visible && current < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int slot = FindFilteredSlot(item);
|
||||
int slot = visible ? FindFilteredSlot(item, current) : -1;
|
||||
if(slot == current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(current >= 0)
|
||||
{
|
||||
FilteredRepos.RemoveAt(current);
|
||||
_filteredSet.Remove(item);
|
||||
}
|
||||
if(visible)
|
||||
{
|
||||
FilteredRepos.Insert(slot, item);
|
||||
_filteredSet.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
FilteredRepos.RemoveAt(slot);
|
||||
_filteredSet.Remove(item);
|
||||
}
|
||||
UpdateAllSelectedFromItems();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Index in <see cref="FilteredRepos"/> where <paramref name="item"/> sits (when
|
||||
/// present) or belongs (when absent), by its position in <see cref="Repos"/>.
|
||||
/// Index at which <paramref name="item"/> belongs in <see cref="FilteredRepos"/>,
|
||||
/// counted without the item itself (<paramref name="current"/> is where it sits
|
||||
/// now, or -1 when it is not in the list), by <see cref="CompareForTable"/>. The
|
||||
/// other rows are in that order already, so a binary search finds it.
|
||||
/// </summary>
|
||||
private int FindFilteredSlot(RepoItemViewModel item)
|
||||
private int FindFilteredSlot(RepoItemViewModel item, int current)
|
||||
{
|
||||
int position = _reposIndex[item];
|
||||
int low = 0;
|
||||
int high = FilteredRepos.Count;
|
||||
int high = current < 0 ? FilteredRepos.Count : FilteredRepos.Count - 1;
|
||||
while(low < high)
|
||||
{
|
||||
int mid = (low + high) / 2;
|
||||
if(_reposIndex[FilteredRepos[mid]] < position)
|
||||
RepoItemViewModel other = FilteredRepos[current >= 0 && mid >= current ? mid + 1 : mid];
|
||||
if(CompareForTable(other, item) < 0)
|
||||
{
|
||||
low = mid + 1;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,104 @@
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
</NavigationView.FooterMenuItems>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Sync all (#167): one row above the workspace, there for the whole pass
|
||||
whichever workspace is on screen - which account, which of how many,
|
||||
how far along - laid out like the workspace's own toolbar (progress
|
||||
and Cancel on the right) and on the same left and right edges as the
|
||||
workspace's cards. Afterwards it holds the result until it is closed.
|
||||
Filled from SyncAllStatus in code-behind. -->
|
||||
<Grid
|
||||
x:Name="SyncAllBar"
|
||||
Grid.Row="0"
|
||||
Margin="20,20,20,0"
|
||||
MinHeight="48"
|
||||
Padding="12,8,12,8"
|
||||
Background="{ThemeResource LayerFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
x:Name="SyncAllIcon"
|
||||
Grid.Column="0"
|
||||
Margin="0,0,12,0"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
x:Name="SyncAllTitleText"
|
||||
Grid.Column="1"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AutomationId="SyncAllTitle"
|
||||
AutomationProperties.LiveSetting="Polite"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock
|
||||
x:Name="SyncAllMessageText"
|
||||
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. -->
|
||||
<StackPanel
|
||||
Grid.Column="3"
|
||||
Margin="12,0,0,0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<ProgressBar
|
||||
x:Name="SyncAllProgressBar"
|
||||
Width="100"
|
||||
Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="Progress of the account that is syncing"
|
||||
Maximum="1" />
|
||||
<Button
|
||||
x:Name="SyncAllShowAccountButton"
|
||||
AutomationProperties.AutomationId="SyncAllShowAccount"
|
||||
Content="Show account"
|
||||
ToolTipService.ToolTip="Go to the account that is syncing now"
|
||||
Click="SyncAllShowAccount_Click" />
|
||||
<Button
|
||||
x:Name="SyncAllCancelButton"
|
||||
AutomationProperties.AutomationId="SyncAllCancel"
|
||||
Content="Cancel sync all"
|
||||
ToolTipService.ToolTip="Stop after the account that is syncing now"
|
||||
Click="SyncAllCancel_Click" />
|
||||
<Button
|
||||
x:Name="SyncAllCloseButton"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
AutomationProperties.AutomationId="SyncAllClose"
|
||||
AutomationProperties.Name="Close"
|
||||
ToolTipService.ToolTip="Close"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Click="SyncAllClose_Click">
|
||||
<FontIcon FontSize="10" Glyph="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- The selected workspace's page, set from code-behind. -->
|
||||
<ContentPresenter x:Name="WorkspaceHost" Grid.Row="1" />
|
||||
</Grid>
|
||||
</NavigationView>
|
||||
|
||||
<!-- Branded startup splash: covers the whole window until MainWindow dismisses
|
||||
|
||||
+145
-13
@@ -86,6 +86,15 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>Where a sync-all pass stands; the bar above the workspaces shows it (#167).</summary>
|
||||
private readonly SyncAllStatus _syncAllStatus = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tells the responsiveness monitor what is running, so its reports in the activity
|
||||
/// log can name it (#167). The monitor itself lives from the constructor to Closed.
|
||||
/// </summary>
|
||||
private readonly Action<string?> _reportActivity;
|
||||
|
||||
/// <summary>Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync).</summary>
|
||||
private readonly Dictionary<Guid, WorkspaceEntry> _workspaces = new();
|
||||
|
||||
@@ -142,6 +151,13 @@ public sealed partial class MainWindow : Window
|
||||
WorkspaceNav.MenuItems.Add(CreateAccountNavItem(account));
|
||||
}
|
||||
UpdateSyncAllEnabled();
|
||||
_syncAllStatus.PropertyChanged += (_, _) => RenderSyncAllBar();
|
||||
|
||||
// Answers on this thread, measured from a timer thread: the log then says when
|
||||
// the window stopped responding instead of leaving that to be guessed.
|
||||
Microsoft.UI.Dispatching.DispatcherQueue queue = DispatcherQueue;
|
||||
var responsiveness = new ResponsivenessMonitor(answer => queue.TryEnqueue(() => answer()), _log);
|
||||
_reportActivity = activity => responsiveness.Activity = activity;
|
||||
|
||||
_settings = AppSettings.Load(_log);
|
||||
|
||||
@@ -182,6 +198,7 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
responsiveness.Dispose();
|
||||
_logWindow?.Close(); // a log-only process would linger otherwise
|
||||
DisposeWorkspaces();
|
||||
_log.Info("gclo closing.");
|
||||
@@ -380,6 +397,7 @@ public sealed partial class MainWindow : Window
|
||||
if(_workspaces.TryGetValue(id, out WorkspaceEntry? shown))
|
||||
{
|
||||
_currentWorkspaceId = id;
|
||||
RenderSyncAllBar(); // the link to the syncing account depends on what is on screen
|
||||
_log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'.");
|
||||
}
|
||||
}
|
||||
@@ -419,7 +437,7 @@ public sealed partial class MainWindow : Window
|
||||
if(EnsureWorkspace(id) is { } workspace)
|
||||
{
|
||||
workspace.Page ??= CreatePage(workspace.ViewModel);
|
||||
WorkspaceNav.Content = workspace.Page;
|
||||
WorkspaceHost.Content = workspace.Page;
|
||||
AutoLoadAccountRepositories(workspace.ViewModel);
|
||||
}
|
||||
}
|
||||
@@ -495,6 +513,11 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
OnWorkspaceStateChanged(id);
|
||||
}
|
||||
if(e.PropertyName is nameof(WorkspaceViewModel.IsRunning)
|
||||
or nameof(WorkspaceViewModel.IsLoadingRepos))
|
||||
{
|
||||
_reportActivity(DescribeActivity());
|
||||
}
|
||||
else if(e.PropertyName is nameof(WorkspaceViewModel.CompletedCount)
|
||||
&& viewModel.IsRunning)
|
||||
{
|
||||
@@ -967,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))
|
||||
{
|
||||
@@ -980,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>
|
||||
@@ -988,8 +1013,7 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
if(_cancelSyncAll is not null)
|
||||
{
|
||||
_log.Info("Sync all: cancel requested; the account in flight will finish.");
|
||||
_cancelSyncAll();
|
||||
RequestSyncAllCancel();
|
||||
return;
|
||||
}
|
||||
_log.Info("Sync all requested.");
|
||||
@@ -1021,26 +1045,24 @@ public sealed partial class MainWindow : Window
|
||||
_syncAllStates.Clear();
|
||||
var coordinator = new SyncAllCoordinator(_log)
|
||||
{
|
||||
Status = _syncAllStatus,
|
||||
AccountStateChanged = (accountId, state) =>
|
||||
{
|
||||
_syncAllStates[accountId] = state;
|
||||
OnWorkspaceStateChanged(accountId);
|
||||
},
|
||||
};
|
||||
SyncAllResult result = await coordinator.RunAsync(accountWorkspaces, cts.Token);
|
||||
|
||||
string accounts = result.Ran == 1 ? "1 account" : $"{result.Ran} accounts";
|
||||
string message = result.WasCanceled
|
||||
? $"Sync all was canceled: {accounts} synced, {result.Skipped} skipped."
|
||||
: $"Sync all finished: {accounts} synced, {result.Skipped} skipped.";
|
||||
await ShowMessageAsync("Sync all", message);
|
||||
// The bar says where the pass stands while it runs and how it ended
|
||||
// afterwards, so nothing has to be confirmed in a dialog (#167).
|
||||
await coordinator.RunAsync(accountWorkspaces, cts.Token);
|
||||
_log.Info($"{_syncAllStatus.Title}: {_syncAllStatus.Message}");
|
||||
}
|
||||
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
|
||||
@@ -1053,6 +1075,116 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>Stops the sync-all queue after the account in flight, and says so in the bar.</summary>
|
||||
private void RequestSyncAllCancel()
|
||||
{
|
||||
if(_cancelSyncAll is null || _syncAllStatus.IsCancelRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_log.Info("Sync all: cancel requested; the account in flight will finish.");
|
||||
_cancelSyncAll();
|
||||
_syncAllStatus.RequestCancel();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Puts <see cref="_syncAllStatus"/> on the bar; runs on every change of it and
|
||||
/// when another workspace comes on screen (the link to the syncing account is
|
||||
/// only offered while that account is not the one being looked at).
|
||||
/// </summary>
|
||||
private void RenderSyncAllBar()
|
||||
{
|
||||
SyncAllStatus status = _syncAllStatus;
|
||||
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);
|
||||
|
||||
SyncAllProgressBar.Visibility = status.IsRunning ? Visibility.Visible : Visibility.Collapsed;
|
||||
SyncAllProgressBar.IsIndeterminate = status.IsRunning && status.Fraction is null;
|
||||
SyncAllProgressBar.Value = status.Fraction ?? 0;
|
||||
|
||||
SyncAllShowAccountButton.Visibility =
|
||||
status.CurrentAccountId is Guid running && running != _currentWorkspaceId
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
SyncAllCancelButton.Visibility = status.IsRunning ? Visibility.Visible : Visibility.Collapsed;
|
||||
SyncAllCancelButton.IsEnabled = !status.IsCancelRequested;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The bar's close button: the result of the last pass has been read.</summary>
|
||||
private void SyncAllClose_Click(object sender, RoutedEventArgs e) => _syncAllStatus.IsOpen = false;
|
||||
|
||||
|
||||
|
||||
/// <summary>The bar's Cancel button: the same as activating 'Cancel sync all' in the pane.</summary>
|
||||
private void SyncAllCancel_Click(object sender, RoutedEventArgs e) => RequestSyncAllCancel();
|
||||
|
||||
|
||||
|
||||
/// <summary>The bar's link to the account that is syncing: puts its workspace on screen.</summary>
|
||||
private void SyncAllShowAccount_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(_syncAllStatus.CurrentAccountId is Guid id && _navItems.TryGetValue(id, out NavigationViewItem? item))
|
||||
{
|
||||
WorkspaceNav.SelectedItem = item; // SelectionChanged shows the workspace
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What is running, in words that follow "while", for the responsiveness report in
|
||||
/// the activity log; null when every workspace is idle.
|
||||
/// </summary>
|
||||
private string? DescribeActivity()
|
||||
{
|
||||
var busy = new List<string>();
|
||||
foreach(WorkspaceEntry workspace in _workspaces.Values)
|
||||
{
|
||||
WorkspaceViewModel viewModel = workspace.ViewModel;
|
||||
if(viewModel.IsRunning)
|
||||
{
|
||||
busy.Add($"syncing '{viewModel.DisplayName}'");
|
||||
}
|
||||
else if(viewModel.IsLoadingRepos)
|
||||
{
|
||||
busy.Add($"listing the repositories of '{viewModel.DisplayName}'");
|
||||
}
|
||||
}
|
||||
return busy.Count == 0 ? null : string.Join(" and ", busy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 'Sync all' is only actionable with at least one account — or while a run is
|
||||
/// in flight, when the item must stay enabled so it can be canceled.
|
||||
|
||||
+16
-1
@@ -149,6 +149,21 @@ public static class StatusFormat
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Icon and its color for a sync-all pass: the sync glyph while it runs, then how
|
||||
/// it ended, in the same glyphs and colors the table uses for a row's status.
|
||||
/// </summary>
|
||||
public static (string Glyph, Brush Brush) IconFor(SyncAllOutcome outcome) => outcome switch
|
||||
{
|
||||
SyncAllOutcome.Finished => ("\uE73E", ThemedBrush("SystemFillColorSuccessBrush")),
|
||||
SyncAllOutcome.FinishedWithFailures => ("\uE7BA", ThemedBrush("SystemFillColorCautionBrush")),
|
||||
SyncAllOutcome.Canceled => ("\uE711", ThemedBrush("TextFillColorSecondaryBrush")),
|
||||
SyncAllOutcome.Stopped => ("\uEA39", ThemedBrush("SystemFillColorCriticalBrush")),
|
||||
_ => ("\uE895", ThemedBrush("SystemFillColorAttentionBrush")), // Running, None
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>Short InfoBar title for a run outcome; empty for <see cref="RunResultKind.None"/>.</summary>
|
||||
public static string TitleFor(RunResultKind kind) => kind switch
|
||||
{
|
||||
@@ -179,7 +194,7 @@ public static class StatusFormat
|
||||
|
||||
|
||||
|
||||
/// <summary>Screen-reader name for a repository's progress bar in the active strip.</summary>
|
||||
/// <summary>Screen-reader name for a repository's progress bar in its table row.</summary>
|
||||
public static string ProgressAutomationName(string repoName)
|
||||
=> $"{repoName} sync progress";
|
||||
|
||||
|
||||
+38
-70
@@ -273,7 +273,7 @@
|
||||
</InfoBar.ActionButton>
|
||||
</InfoBar>
|
||||
|
||||
<!-- Hero table card: toolbar, active strip, results InfoBar, header, rows. -->
|
||||
<!-- Hero table card: toolbar, results InfoBar, header, rows (the ones in flight on top). -->
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Padding="12"
|
||||
@@ -285,7 +285,6 @@
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
@@ -313,6 +312,7 @@
|
||||
x:Name="FilterSelectorBar"
|
||||
AutomationProperties.AutomationId="RepoFilterBar"
|
||||
Grid.Column="0"
|
||||
Margin="-8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
SelectionChanged="FilterSelectorBar_SelectionChanged">
|
||||
<SelectorBarItem x:Name="FilterAllItem" Text="All" Tag="All" ToolTipService.ToolTip="Show every loaded repository" />
|
||||
@@ -337,6 +337,13 @@
|
||||
Spacing="8"
|
||||
SizeChanged="Toolbar_SizeChanged"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}">
|
||||
<Button
|
||||
AutomationProperties.AutomationId="OpenFolderButton"
|
||||
AutomationProperties.Name="Open folder"
|
||||
ToolTipService.ToolTip="Open the folder the repositories are cloned into"
|
||||
Click="OpenFolderButton_Click">
|
||||
<FontIcon FontSize="16" Glyph="" />
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="OptionsButton"
|
||||
AutomationProperties.AutomationId="SyncOptionsButton"
|
||||
@@ -426,79 +433,25 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Active strip: exactly the repos cloning/pulling right now, bounded by
|
||||
the parallelism setting, so the table never moves under the user.
|
||||
Three whole rows of chips are visible; beyond that (parallelism up
|
||||
to 64) it scrolls instead of clipping chips to a sliver (#31). -->
|
||||
<ScrollViewer
|
||||
Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
MaxHeight="102"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsRunning), Mode=OneWay}">
|
||||
<ItemsControl
|
||||
x:Name="ActiveStrip"
|
||||
AutomationProperties.Name="Repositories syncing now"
|
||||
ItemsSource="{x:Bind ViewModel.ActiveRepos, Mode=OneTime}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VariableSizedWrapGrid Orientation="Horizontal" ItemWidth="208" ItemHeight="30" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<Border
|
||||
Margin="0,0,8,4"
|
||||
Padding="10,4"
|
||||
Background="{ThemeResource ControlFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="13">
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
<ProgressBar
|
||||
Grid.Column="1"
|
||||
Width="56"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind local:StatusFormat.ProgressAutomationName(Name), Mode=OneTime}"
|
||||
Maximum="1"
|
||||
Value="{x:Bind ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Run results: opens when a run ends, user-dismissable (two-way IsOpen). -->
|
||||
<InfoBar
|
||||
Grid.Row="2"
|
||||
Grid.Row="1"
|
||||
AutomationProperties.AutomationId="ResultInfoBar"
|
||||
Margin="0,8,0,0"
|
||||
IsOpen="{x:Bind ViewModel.ResultOpen, Mode=TwoWay}"
|
||||
Severity="{x:Bind local:StatusFormat.SeverityFor(ViewModel.ResultKind), Mode=OneWay}"
|
||||
Title="{x:Bind local:StatusFormat.TitleFor(ViewModel.ResultKind), Mode=OneWay}"
|
||||
Message="{x:Bind ViewModel.ResultMessage, Mode=OneWay}">
|
||||
<StackPanel
|
||||
Margin="0,0,0,12"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<!-- One action, beside the message, so the bar stays one row: retry
|
||||
when something failed, otherwise the folder. The folder is always
|
||||
in the toolbar as well. -->
|
||||
<InfoBar.ActionButton>
|
||||
<Button
|
||||
Content="Retry failed"
|
||||
Command="{x:Bind ViewModel.RetryFailedCommand, Mode=OneTime}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasFailedRepos), Mode=OneWay}" />
|
||||
<Button Content="Open folder" Click="OpenFolderButton_Click" />
|
||||
</StackPanel>
|
||||
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>
|
||||
|
||||
<!-- Repository table: WinUI.TableView (#33). The view model owns sort and
|
||||
@@ -515,7 +468,7 @@
|
||||
x:Name="RepoTable"
|
||||
AutomationProperties.AutomationId="RepoListView"
|
||||
AutomationProperties.Name="Repositories"
|
||||
Grid.Row="3"
|
||||
Grid.Row="2"
|
||||
Grid.RowSpan="2"
|
||||
Margin="0,8,0,0"
|
||||
DataContext="{x:Bind ViewModel, Mode=OneTime}"
|
||||
@@ -536,6 +489,21 @@
|
||||
Sorting="RepoTable_Sorting"
|
||||
ClearSorting="RepoTable_ClearSorting"
|
||||
Loaded="RepoTable_Loaded">
|
||||
<!-- A row of this table costs about 20 ms to build. The default keeps
|
||||
two viewports of rows ready on each side, which made showing a
|
||||
large account take over a second; half a viewport on each side
|
||||
is enough for scrolling to stay smooth (#167). -->
|
||||
<tv:TableView.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<ItemsStackPanel CacheLength="1" />
|
||||
</ItemsPanelTemplate>
|
||||
</tv:TableView.ItemsPanel>
|
||||
<!-- Rows go to the top while they are in flight and back when they
|
||||
are done, several a second in a large run: they take their new
|
||||
place at once instead of each one sliding and fading (#168). -->
|
||||
<tv:TableView.ItemContainerTransitions>
|
||||
<TransitionCollection />
|
||||
</tv:TableView.ItemContainerTransitions>
|
||||
<tv:TableView.Columns>
|
||||
|
||||
<!-- Select: header checkbox acts on the VISIBLE rows (AllSelected). -->
|
||||
@@ -662,7 +630,7 @@
|
||||
</tv:TableViewTemplateColumn.Header>
|
||||
<tv:TableViewTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2">
|
||||
<StackPanel Margin="10,0,12,0" VerticalAlignment="Center" Spacing="3">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
@@ -827,7 +795,7 @@
|
||||
<!-- Shown when the current filter hides everything the table has. -->
|
||||
<TextBlock
|
||||
x:Name="EmptyFilterText"
|
||||
Grid.Row="4"
|
||||
Grid.Row="3"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
@@ -840,7 +808,7 @@
|
||||
<TextBlock
|
||||
x:Name="EmptyTableText"
|
||||
AutomationProperties.AutomationId="EmptyTableText"
|
||||
Grid.Row="4"
|
||||
Grid.Row="3"
|
||||
MaxWidth="480"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
|
||||
@@ -22,8 +22,8 @@ namespace gclo;
|
||||
/// <summary>
|
||||
/// One sync workspace in two visual states: a centered connect card until the first
|
||||
/// successful load, then a connection chip plus the hero repository table with its
|
||||
/// attached toolbar (filter, options, refresh, sync), the pinned active strip, and
|
||||
/// the results InfoBar. Hosted by <see cref="MainWindow"/>'s navigation shell —
|
||||
/// attached toolbar (filter, options, refresh, sync) and the results InfoBar; the
|
||||
/// rows in flight sit at the top of the table. Hosted by <see cref="MainWindow"/>'s navigation shell —
|
||||
/// one instance per saved account plus one for the pinned Quick Sync entry. Pages
|
||||
/// (and their view models) are cached by the shell, so a running sync keeps going
|
||||
/// while another workspace is displayed.
|
||||
@@ -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)
|
||||
{
|
||||
@@ -496,6 +497,38 @@ public sealed partial class WorkspacePage : UserControl
|
||||
|
||||
|
||||
|
||||
/// <summary>Label of the result bar's one action: retry when something failed, otherwise the folder.</summary>
|
||||
public string ResultActionLabel(bool hasFailedRepos) => hasFailedRepos ? "Retry failed" : "Open folder";
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string root = ViewModel.EffectiveTargetRoot;
|
||||
@@ -504,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