From f426a2f7b536e6a20e0445f596f3afcb44a832ad Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 18:56:07 -0600 Subject: [PATCH 1/2] fix(app): the window stays responsive while a large account syncs, Sync all says where it stands, and the table follows the sync Progress reports were delivered to the window one by one. A clone reports every whole percent, so 281 clones are 28,662 reports; with the rows on screen they took 27.5 s of the window's thread in a 60 s pass, and the window stopped answering for up to a second at a time (23 stalls, 11.6 s). - BatchedRepoProgress delivers reports a few times a second, the latest per repository, and what still waits when the run ends is delivered before the outcome is stated. The same pass now has no stall with the rows at rest. - SyncAllStatus says for the whole pass which account is syncing, which of how many, and how far along it is, and holds the result afterwards. The window shows it in one row above the workspace, with a link to the account and Cancel. It replaces the dialog at the end of the pass. - ResponsivenessMonitor writes to the activity log when the window did not respond, for how long, and what was running. - The table's default order follows the sync: the repositories in progress, then the failed ones, then the finished ones (the latest first), then the ones waiting; when the sync is over, the failed ones first, then by name. A column the user chose still wins, and a third click on it returns to the default. The row of chips above the table is gone: those rows are in the table. - The result bar is one row (Retry failed when something failed, otherwise Open folder); Open folder is also a toolbar button. Fewer rows are built ahead of the viewport, and rows take their new place without animation. Closes #167 Closes #168 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- README.md | 10 +- gclo.Engine.Tests/BatchedRepoProgressTests.cs | 190 +++++++++++++ gclo.Engine.Tests/ManualTime.cs | 114 ++++++++ gclo.Engine.Tests/PumpedContext.cs | 62 +++++ .../ResponsivenessMonitorTests.cs | 242 +++++++++++++++++ gclo.Engine.Tests/SyncAllCoordinatorTests.cs | 252 ++++++++++++++++++ gclo.Engine.Tests/WorkspaceViewModelTests.cs | 203 ++++++++++++-- gclo.ViewModels/BatchedRepoProgress.cs | 163 +++++++++++ gclo.ViewModels/RepoItemViewModel.cs | 7 + gclo.ViewModels/ResponsivenessMonitor.cs | 247 +++++++++++++++++ gclo.ViewModels/SyncAllCoordinator.cs | 38 ++- gclo.ViewModels/SyncAllOutcome.cs | 29 ++ gclo.ViewModels/SyncAllResult.cs | 6 +- gclo.ViewModels/SyncAllStatus.cs | 236 ++++++++++++++++ gclo.ViewModels/WorkspaceViewModel.cs | 233 +++++++++++----- gclo/MainWindow.xaml | 99 +++++++ gclo/MainWindow.xaml.cs | 134 +++++++++- gclo/StatusFormat.cs | 17 +- gclo/WorkspacePage.xaml | 107 +++----- gclo/WorkspacePage.xaml.cs | 24 +- 20 files changed, 2233 insertions(+), 180 deletions(-) create mode 100644 gclo.Engine.Tests/BatchedRepoProgressTests.cs create mode 100644 gclo.Engine.Tests/ManualTime.cs create mode 100644 gclo.Engine.Tests/PumpedContext.cs create mode 100644 gclo.Engine.Tests/ResponsivenessMonitorTests.cs create mode 100644 gclo.ViewModels/BatchedRepoProgress.cs create mode 100644 gclo.ViewModels/ResponsivenessMonitor.cs create mode 100644 gclo.ViewModels/SyncAllOutcome.cs create mode 100644 gclo.ViewModels/SyncAllStatus.cs diff --git a/README.md b/README.md index 4ba76be..d174ac5 100644 --- a/README.md +++ b/README.md @@ -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 `` 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.

Loaded repository table: 59 repositories all selected, with sortable, filterable Name / Status / Branch / Archived columns and a Sync 59 repos button

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.

Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59

-8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, **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 at any time. **When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo//` inside WSL (reachable from Explorer as `\wsl.localhost\\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). **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.

Add account wizard, step 1 of 4: account name and optional description diff --git a/gclo.Engine.Tests/BatchedRepoProgressTests.cs b/gclo.Engine.Tests/BatchedRepoProgressTests.cs new file mode 100644 index 0000000..5281ba4 --- /dev/null +++ b/gclo.Engine.Tests/BatchedRepoProgressTests.cs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +///

+/// 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). +/// +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 _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(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 AMissingHandlerOrReport_IsRefused() + { + Assert.Throws(() => new BatchedRepoProgress(null!)); + using BatchedRepoProgress progress = Create(); + Assert.Throws(() => progress.Report(null!)); + } +} diff --git a/gclo.Engine.Tests/ManualTime.cs b/gclo.Engine.Tests/ManualTime.cs new file mode 100644 index 0000000..d913bc9 --- /dev/null +++ b/gclo.Engine.Tests/ManualTime.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine.Tests; + + +/// +/// 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. +/// +internal sealed class ManualTime : TimeProvider +{ + private readonly List _timers = []; + + private long _now; + + + + /// Every timer created from this clock, in creation order. + public IReadOnlyList Timers + { + get + { + lock(_timers) + { + return [.. _timers]; + } + } + } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + + + public override long GetTimestamp() => Interlocked.Read(ref _now); + + + + /// Moves the clock forward; fires nothing. + 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; + } + + + + /// One timer: armed or not, fired by the test. + internal sealed class ManualTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) : ITimer + { + /// When it is next due, counted from the last arming; infinite when it is not armed. + public TimeSpan DueTime { get; private set; } = dueTime; + + /// Its period; infinite for a timer that fires once per arming. + public TimeSpan Period { get; private set; } = period; + + /// How many times it was armed again after it was created. + 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; + } + + + + /// Runs the callback as the timer would when due; a timer without a period is then no longer armed. + 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; + } + } +} diff --git a/gclo.Engine.Tests/PumpedContext.cs b/gclo.Engine.Tests/PumpedContext.cs new file mode 100644 index 0000000..ec4a83e --- /dev/null +++ b/gclo.Engine.Tests/PumpedContext.cs @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine.Tests; + + +/// +/// 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. +/// +internal sealed class PumpedContext : SynchronizationContext +{ + private readonly Queue<(SendOrPostCallback Callback, object? State)> _posted = new(); + + + + /// How many posted callbacks wait to be run. + public int Waiting + { + get + { + lock(_posted) + { + return _posted.Count; + } + } + } + + + + public override void Post(SendOrPostCallback d, object? state) + { + lock(_posted) + { + _posted.Enqueue((d, state)); + } + } + + + + /// Runs everything posted so far, on the calling thread; returns how many callbacks ran. + 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++; + } + } +} diff --git a/gclo.Engine.Tests/ResponsivenessMonitorTests.cs b/gclo.Engine.Tests/ResponsivenessMonitorTests.cs new file mode 100644 index 0000000..d0c0261 --- /dev/null +++ b/gclo.Engine.Tests/ResponsivenessMonitorTests.cs @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +/// +/// 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). +/// +public sealed class ResponsivenessMonitorTests +{ + private readonly ManualTime _time = new(); + + private readonly RecordingActivityLog _log = new(); + + /// What the monitor queued on the window's thread and the test has not run yet. + private readonly Queue _asked = new(); + + private bool _windowTakesWork = true; + + + + private ResponsivenessMonitor Create() + => new( + answer => + { + if(_windowTakesWork) + { + _asked.Enqueue(answer); + } + return _windowTakesWork; + }, + _log, + _time); + + + + /// The window's thread answers the question in flight after . + private void Answer(TimeSpan after) + { + _time.Advance(after); + _asked.Dequeue()(); + } + + + + /// The window answers at once, and nothing happens until the quiet period after a report is over. + private void QuietPeriodPasses(ResponsivenessMonitor monitor) + { + Answer(TimeSpan.Zero); + _time.Advance(ResponsivenessMonitor.QuietPeriod); + monitor.Check(); + } + + + + /// One stall of the given length: asked, answered late, noticed. + 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 + + _time.Advance(TimeSpan.FromSeconds(4)); + monitor.Check(); + Assert.Empty(_log.Entries); // not long enough yet + + _time.Advance(TimeSpan.FromSeconds(1)); + monitor.Check(); + monitor.Check(); // said once, not on every look + Assert.Equal([("ERROR", "The window has not responded for 5000 ms and still does not.")], _log.Entries); + Assert.Single(_asked); // no second question while the first is unanswered + + Stall(monitor, 2000); + Assert.Equal("The window did not respond for 7000 ms.", _log.Messages[^1]); + } + + + + [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(() => new ResponsivenessMonitor(null!, _log)); + Assert.Throws(() => new ResponsivenessMonitor(_ => true, null!)); + } +} diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs index 5ac4ff6..a8ab7d9 100644 --- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -548,4 +548,256 @@ 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) + + /// A clone that waits until the test lets it go, and says when it was entered. + 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(); + 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 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(); + status.PropertyChanged += (_, e) => + { + if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Fraction), StringComparison.Ordinal)) + { + fractions.Add(status.Fraction); + } + }; + + Task 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 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(() => _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 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(() => status.Enter(1, null!)); + Assert.Throws(() => status.End(null!)); + } } diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 7134d4f..1814fce 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -454,41 +454,147 @@ public sealed class WorkspaceViewModelTests : IDisposable - // ---------------------------------------------------------------- active strip + // ---------------------------------------------------------------- the table's default order (#168) - [Fact] - public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds() + private static string[] TableOrder(WorkspaceViewModel vm) => [.. vm.FilteredRepos.Select(r => r.Name)]; + + + + /// A clone per repository that waits until the test lets that repository go (or fails it). + private Dictionary GateEachClone(params string[] names) { - var gateAlpha = new TaskCompletionSource(); - var gateBravo = new TaskCompletionSource(); + Dictionary 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 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"); + Assert.Equal(["alpha", "bravo", "charlie", "delta"], TableOrder(vm)); - 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"); + Assert.Equal(["bravo", "alpha", "charlie", "delta"], TableOrder(vm)); // in flight, finished, waiting - gateBravo.SetResult(); + gates["bravo"].SetResult(); + await WaitUntilAsync(() => byName["charlie"].Status == SyncStatus.Cloning, "charlie to start"); + Assert.Equal(["charlie", "bravo", "alpha", "delta"], TableOrder(vm)); // bravo finished after alpha: it is above it + + 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. + Assert.Equal(["alpha", "bravo", "charlie", "delta"], TableOrder(vm)); Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status)); + } + + + [Fact] + public async Task AFailedRow_IsBelowTheOnesInFlight_WhileTheSyncRuns_AndFirstWhenItIsOver() + { + Dictionary 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"); + + Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm)); // in flight, failed, finished + + gates["charlie"].SetResult(); + await run; + + Assert.Equal(["bravo", "alpha", "charlie"], TableOrder(vm)); // the failed one first, then by name + } + + + + [Fact] + public async Task TheDefaultOrder_IsByName_WhateverOrderTheRepositoriesWereListedIn() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("Alpha"), Repo("bravo")); + + Assert.Equal(["Alpha", "bravo", "charlie"], TableOrder(vm)); + Assert.Null(vm.SortColumn); + } + + + + [Fact] + public async Task AColumnTheUserChose_Wins_AndAThirdClickOnIt_ReturnsToTheDefaultOrder() + { + Dictionary 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); + Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm)); + + // 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"); + Assert.Equal(["charlie", "bravo", "alpha"], TableOrder(vm)); + + // The third click gives the column up: the default order is back, with the row + // in flight on top, then the finished one, then the one still waiting. + vm.SortCommand.Execute("Name"); + Assert.Null(vm.SortColumn); + Assert.False(vm.SortDescending); + Assert.Equal(["bravo", "charlie", "alpha"], TableOrder(vm)); + + gates["bravo"].SetResult(); + gates["alpha"].SetResult(); + await run; + Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm)); + } + + + + [Fact] + public async Task AFilter_KeepsTheDefaultOrder_AndARowThatLeavesItAndReturns_ComesBackInItsPlace() + { + Dictionary 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"); + Assert.Equal(["bravo", "charlie"], TableOrder(vm)); // alpha is no longer waiting + + vm.Filter = RepoFilter.All; + Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm)); + + gates["alpha"].SetResult(); + gates["bravo"].SetResult(); + gates["charlie"].SetResult(); + await run; + Assert.Equal(["alpha", "bravo", "charlie"], TableOrder(vm)); } @@ -1364,8 +1470,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 +1515,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 +2924,52 @@ 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 progress = WorkspaceViewModel.DefaultProgress(_ => { }); + + using BatchedRepoProgress batched = Assert.IsType(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); + } } diff --git a/gclo.ViewModels/BatchedRepoProgress.cs b/gclo.ViewModels/BatchedRepoProgress.cs new file mode 100644 index 0000000..98d3f09 --- /dev/null +++ b/gclo.ViewModels/BatchedRepoProgress.cs @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// 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. +/// +public sealed class BatchedRepoProgress : IProgress, IDisposable +{ + /// How long a report waits at most before it is delivered. + public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(100); + + private readonly Action _handler; + + private readonly SynchronizationContext _context; + + private readonly TimeSpan _interval; + + private readonly ITimer _timer; + + private readonly Lock _gate = new(); + + /// Reports not delivered yet, oldest first. Guarded by . + private List _pending = []; + + /// True while a delivery is on its way. Guarded by . + private bool _deliveryScheduled; + + /// True once the timer is gone. Guarded by . + private bool _disposed; + + + + /// Creates the progress; call it on the thread the reports are to be delivered on. + /// Receives each delivered report, on the delivery thread. + /// Where to deliver; the calling thread's context by default. + /// How long a report waits at most; by default. + /// The timer source; the system's by default. + public BatchedRepoProgress( + Action 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); + } + + + + /// Takes a report from any thread; it is delivered with the next batch. + /// The report. + 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(); + } + } + + + + /// + /// 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. + /// + public void Dispose() + { + lock(_gate) + { + _disposed = true; + } + _timer.Dispose(); + Deliver(); + } + + + + /// + /// Of several reports for one repository, the latest; repositories stay in the + /// order in which each was first reported. + /// + internal static List LatestPerRepository(List reports) + { + var latest = new List(reports.Count); + var slots = new Dictionary(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 batch; + lock(_gate) + { + batch = _pending; + _pending = []; + _deliveryScheduled = false; + } + + foreach(RepoProgress report in LatestPerRepository(batch)) + { + _handler(report); + } + } +} diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 00fd06d..2ff6f51 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -23,6 +23,13 @@ public sealed partial class RepoItemViewModel : ObservableObject + /// + /// 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). + /// + internal long LastActivity { get; set; } + /// The repository this row represents; never changes after construction. public RepoDescriptor Descriptor { get; } diff --git a/gclo.ViewModels/ResponsivenessMonitor.cs b/gclo.ViewModels/ResponsivenessMonitor.cs new file mode 100644 index 0000000..9f6c783 --- /dev/null +++ b/gclo.ViewModels/ResponsivenessMonitor.cs @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Globalization; +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// 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 are counted +/// and reported together, so a busy run cannot fill the log. +/// +public sealed class ResponsivenessMonitor : IDisposable +{ + /// How often the window's thread is asked to answer. + public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(100); + + /// An answer that takes at least this long is reported. + public static readonly TimeSpan DefaultThreshold = TimeSpan.FromMilliseconds(250); + + /// A window that has not answered for this long is reported while it still has not. + public static readonly TimeSpan StillWaitingAfter = TimeSpan.FromSeconds(5); + + /// After a report, how long further stalls are collected before they are reported together. + public static readonly TimeSpan QuietPeriod = TimeSpan.FromSeconds(10); + + private readonly Func _post; + + private readonly IActivityLog _log; + + private readonly TimeProvider _time; + + private readonly TimeSpan _threshold; + + private readonly ITimer _timer; + + private readonly Lock _gate = new(); + + /// When the question in flight was asked. + private long _askedAt; + + /// When it was answered; valid once is true. + private long _answeredAt; + + private volatile bool _answered; + + /// True while a question is in flight. + private bool _asked; + + private bool _saidStillWaiting; + + private bool _stopped; + + /// When the last stall got its own line; stalls are collected from then on. Null when the next one gets a line. + private long? _quietSince; + + private int _collected; + + private TimeSpan _collectedTotal; + + private TimeSpan _collectedLongest; + + private volatile string? _activity; + + + + /// Starts watching. + /// + /// Queues an action on the window's thread; false when that thread no longer takes + /// work (the window is closing), which ends the watch. + /// + /// Where stalls are reported. + /// Clock and timer source; the system's by default. + /// How often to ask; by default. + /// The shortest stall worth a line; by default. + public ResponsivenessMonitor( + Func 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); + } + + + + /// + /// 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. + /// + public string? Activity + { + get => _activity; + set => _activity = value; + } + + + + /// Stops watching; stalls collected but not yet reported are reported now. + public void Dispose() + { + lock(_gate) + { + if(_stopped) + { + return; + } + _stopped = true; + ReportCollected(); + } + _timer.Dispose(); + } + + + + /// + /// One look, on the timer's thread: was the last question answered, how late, and + /// if so ask the next one. + /// + internal void Check() + { + lock(_gate) + { + if(_stopped) + { + return; + } + + if(_quietSince is long since && _time.GetElapsedTime(since) >= QuietPeriod) + { + ReportCollected(); + _quietSince = null; + } + + 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()} 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(); + } + } + + + + /// Runs on the window's thread: nothing but a timestamp, no file is touched there. + private void Answer() + { + _answeredAt = _time.GetTimestamp(); + _answered = true; + } + + + + /// The first stall after a quiet time gets its own line; the ones that follow closely are collected. + private void Note(TimeSpan waited) + { + if(_quietSince is null) + { + _log.Info($"The window did not respond for {Milliseconds(waited)} ms{WhileWhat()}."); + _quietSince = _time.GetTimestamp(); + return; + } + + _collected++; + _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()}: {Milliseconds(_collectedTotal)} ms in total, " + + $"the longest {Milliseconds(_collectedLongest)} ms."); + _collected = 0; + _collectedTotal = TimeSpan.Zero; + _collectedLongest = TimeSpan.Zero; + } + + + + private string WhileWhat() => _activity is { Length: > 0 } activity ? $" while {activity}" : ""; + + + + private static string Milliseconds(TimeSpan span) + => ((long)span.TotalMilliseconds).ToString(CultureInfo.InvariantCulture); +} diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs index 4851797..51ccdf3 100644 --- a/gclo.ViewModels/SyncAllCoordinator.cs +++ b/gclo.ViewModels/SyncAllCoordinator.cs @@ -29,6 +29,12 @@ public sealed class SyncAllCoordinator(IActivityLog log) /// public Action? AccountStateChanged { get; set; } + /// + /// 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. + /// + public SyncAllStatus? Status { get; set; } + /// @@ -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 RunQueueAsync( + IReadOnlyList 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); } diff --git a/gclo.ViewModels/SyncAllOutcome.cs b/gclo.ViewModels/SyncAllOutcome.cs new file mode 100644 index 0000000..187465a --- /dev/null +++ b/gclo.ViewModels/SyncAllOutcome.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// What a sync-all pass is doing, or how the last one ended; see . +public enum SyncAllOutcome +{ + /// No pass has run yet. + None, + + /// A pass is running. + Running, + + /// Every account that ran finished without a failed repository. + Finished, + + /// The pass finished, and at least one account had failures or could not be synced. + FinishedWithFailures, + + /// The pass was canceled before every account had its turn. + Canceled, + + /// The pass stopped on an unexpected error. + Stopped, +} diff --git a/gclo.ViewModels/SyncAllResult.cs b/gclo.ViewModels/SyncAllResult.cs index abb92ce..678bfff 100644 --- a/gclo.ViewModels/SyncAllResult.cs +++ b/gclo.ViewModels/SyncAllResult.cs @@ -12,4 +12,8 @@ namespace gclo.ViewModels; /// Accounts whose sync ran to completion (successfully or not). /// Accounts skipped because they were busy, could not load, or had nothing to sync. /// True when cancellation stopped the queue before every account was processed. -public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled); +/// +/// 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 . +/// +public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled, int Failed = 0); diff --git a/gclo.ViewModels/SyncAllStatus.cs b/gclo.ViewModels/SyncAllStatus.cs new file mode 100644 index 0000000..6b4f938 --- /dev/null +++ b/gclo.ViewModels/SyncAllStatus.cs @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.ComponentModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + + +namespace gclo.ViewModels; + + +/// +/// 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). drives it; the window binds to it, +/// so a pass is visible whichever workspace is on screen. Used on the window's thread +/// only. +/// +public sealed partial class SyncAllStatus : ObservableObject +{ + /// The account in flight; its progress is followed while it is. + private WorkspaceViewModel? _current; + + private int _position; + + private int _accounts; + + + + /// Creates a status with nothing to show. + public SyncAllStatus() + { + Title = ""; + Message = ""; + } + + + + /// True from the start of a pass until its last account is done. + [ObservableProperty] + public partial bool IsRunning { get; private set; } + + /// + /// 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). + /// + [ObservableProperty] + public partial bool IsOpen { get; set; } + + /// Running, or how the last pass ended. + [ObservableProperty] + public partial SyncAllOutcome Outcome { get; private set; } + + /// The headline, for example "Sync all: account 2 of 3". + [ObservableProperty] + public partial string Title { get; private set; } + + /// The detail, for example "Engineering: 120 of 281 repositories.". + [ObservableProperty] + public partial string Message { get; private set; } + + /// The account in flight, for a link to its workspace; null between accounts and after the pass. + [ObservableProperty] + public partial Guid? CurrentAccountId { get; private set; } + + /// + /// 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). + /// + [ObservableProperty] + public partial double? Fraction { get; private set; } + + /// True once the user asked for the pass to stop; the account in flight still finishes. + [ObservableProperty] + public partial bool IsCancelRequested { get; private set; } + + + + /// A pass over accounts starts. + /// How many accounts are scheduled. + 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; + } + + + + /// The pass reached an account; its progress is followed until the next call. + /// Its place in the pass, from 1. + /// Its workspace. + public void Enter(int position, WorkspaceViewModel workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + Release(); + _position = position; + _current = workspace; + workspace.PropertyChanged += OnCurrentChanged; + CurrentAccountId = workspace.AccountId; + Refresh(); + } + + + + /// The user asked for the pass to stop. Says so; the account in flight still finishes. + public void RequestCancel() + { + if(!IsRunning) + { + return; + } + IsCancelRequested = true; + Refresh(); + } + + + + /// The pass ended; the result stays until it is closed. + /// How it ended. + 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; + } + + + + /// The pass stopped on an unexpected error. + /// What went wrong. + public void Stop(string reason) + { + Leave(); + Outcome = SyncAllOutcome.Stopped; + Title = "Sync all stopped"; + Message = reason; + IsOpen = true; + } + + + + /// The sentence that sums up a finished or canceled pass. + 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; + } + + string doing; + if(workspace.IsRunning) + { + 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." : ""); + } + + + + /// Nothing is in flight any more. + private void Leave() + { + Release(); + CurrentAccountId = null; + Fraction = null; + IsRunning = false; + } + + + + private void Release() + { + if(_current is { } workspace) + { + workspace.PropertyChanged -= OnCurrentChanged; + _current = null; + } + } +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index 94d46ef..1c92cae 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -79,6 +79,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable set; } + /// Counts the starts and finishes of rows; see . + private long _activityClock; + /// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other. private bool _syncingSelection; @@ -99,8 +102,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// /// Production dependencies by default; pass fakes for testing. The default progress - /// factory is , which marshals via the SynchronizationContext - /// captured at construction (the UI thread in the app); tests inject a synchronous one. + /// factory is , 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 seeds the workspace from that profile — its /// organization, target folder, subfolder preference, and concurrency — and pulls the /// token from (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(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 /// public ObservableCollection FilteredRepos { get; } = new(); - /// - /// 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 - /// . Cleared when a run starts and again when it ends. - /// - public ObservableCollection ActiveRepos { get; } = new(); - /// Organizations discovered from the current token; feeds the org dropdown. public ObservableCollection Organizations { get; } = new(); @@ -338,7 +335,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [ObservableProperty] public partial bool HasFailedRepos { get; set; } - /// Column the table is currently sorted by, or null for load order. + /// + /// 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). + /// [ObservableProperty] public partial string? SortColumn { get; set; } @@ -996,7 +998,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 +1023,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 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 delivery = _progressFactory(HandleProgress); + var progress = new FailureLoggingProgress(delivery, _log); var request = new SyncRequest( Organization.Trim(), token, targetRoot, MaxConcurrency); @@ -1034,7 +1038,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable Func, IProgress, CancellationToken, Task> 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 +1094,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { HasCompletedRun = true; IsRunning = false; - ActiveRepos.Clear(); RebuildFilteredRepos(); ResultOpen = true; } @@ -1088,6 +1101,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable + /// How progress reaches the window when nothing else was asked for: in batches (#167). + internal static IProgress DefaultProgress(Action handler) + => new BatchedRepoProgress(handler); + + + // ---------------------------------------------------------------- retry private bool CanRetryFailed() => @@ -1211,7 +1230,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable IsResolvingPaths = false; } RecomputeCompletedCount(); - UpdateFilteredMembership(item); + PlaceInTable(item); RetryFailedCommand.NotifyCanExecuteChanged(); } @@ -1225,17 +1244,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 +1279,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 +1293,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 +1305,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// /// 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 ). The re-order is stable. /// [RelayCommand] private void Sort(string column) @@ -1298,7 +1318,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 +1392,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 +1408,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 +1484,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 - /// instead. A no-op when the visible set + /// instead. A no-op when the visible set /// is already correct, so list controls keep their scroll position. /// private void RebuildFilteredRepos() { var desired = Repos.Where(MatchesFilter).ToList(); + desired.Sort(CompareForTable); if(!desired.SequenceEqual(FilteredRepos)) { FilteredRepos.Clear(); @@ -1492,50 +1506,133 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable + /// True while a git operation is running for the row. + private static bool IsInFlight(RepoItemViewModel repo) + => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling; + + + /// - /// Applies one row's current filter match to : inserts it - /// at its table position (binary search on ) 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). /// - 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; + } + + + + /// + /// The table's order, as a comparison (negative: comes first). + /// With a column chosen by the user it is that column's order, which + /// 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. + /// + 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]); + } + + + + /// Sets a row's status outside a sync run (a recovery) and puts the row where it now belongs. + private void SetStatus(RepoItemViewModel item, SyncStatus status) + { + item.Status = status; + item.LastActivity = ++_activityClock; + PlaceInTable(item); + } + + + + /// + /// Brings one row's presence and place in 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. + /// + private void PlaceInTable(RepoItemViewModel item) { 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(); } /// - /// Index in where sits (when - /// present) or belongs (when absent), by its position in . + /// Index at which belongs in , + /// counted without the item itself ( is where it sits + /// now, or -1 when it is not in the list), by . The + /// other rows are in that order already, so a binary search finds it. /// - 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; } diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index c489ff9..52ec108 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -108,6 +108,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - @@ -313,6 +312,7 @@ x:Name="FilterSelectorBar" AutomationProperties.AutomationId="RepoFilterBar" Grid.Column="0" + Margin="-8,0,0,0" VerticalAlignment="Center" SelectionChanged="FilterSelectorBar_SelectionChanged"> @@ -337,6 +337,13 @@ Spacing="8" SizeChanged="Toolbar_SizeChanged" Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}"> +