diff --git a/README.md b/README.md index 4ba76be..3efc2b6 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 whenever no sync is running. **When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo//` inside WSL (reachable from Explorer as `\wsl.localhost\\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). **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..7b4107c --- /dev/null +++ b/gclo.Engine.Tests/BatchedRepoProgressTests.cs @@ -0,0 +1,217 @@ +/* + * 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 AHandlerThatFailsForOneReport_StillGetsTheOthers_AndTheFailureIsNotSwallowed() + { + var delivered = new List(); + using var progress = new BatchedRepoProgress( + report => + { + if(string.Equals(report.RepoName, "alpha", StringComparison.Ordinal)) + { + throw new InvalidOperationException("the row for alpha is gone"); + } + delivered.Add(report.RepoName); + }, + _window, s_interval, _time); + progress.Report(new RepoProgress("alpha", SyncStatus.Done)); + progress.Report(new RepoProgress("bravo", SyncStatus.Done)); + progress.Report(new RepoProgress("charlie", SyncStatus.Failed, Error: "boom")); + Timer.Fire(); + + InvalidOperationException failure = Assert.Throws(() => _window.RunAll()); + + Assert.Equal("the row for alpha is gone", failure.Message); + Assert.Equal(["bravo", "charlie"], delivered, StringComparer.Ordinal); // their final states were not lost + } + + + + [Fact] + public void AMissingHandlerOrReport_IsRefused() + { + 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..e615fb5 --- /dev/null +++ b/gclo.Engine.Tests/ResponsivenessMonitorTests.cs @@ -0,0 +1,296 @@ +/* + * 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 monitor looks every 100 ms, as its timer would, for the given time; nothing answers. + private void Looks(ResponsivenessMonitor monitor, TimeSpan during) + { + for(TimeSpan passed = TimeSpan.Zero; passed < during; passed += ResponsivenessMonitor.DefaultInterval) + { + _time.Advance(ResponsivenessMonitor.DefaultInterval); + monitor.Check(); + } + } + + + + /// The window answers every question at once until the quiet period after a report is over. + private void QuietPeriodPasses(ResponsivenessMonitor monitor) + { + for(TimeSpan passed = TimeSpan.Zero; passed <= ResponsivenessMonitor.QuietPeriod; passed += ResponsivenessMonitor.DefaultInterval) + { + Answer(TimeSpan.Zero); + _time.Advance(ResponsivenessMonitor.DefaultInterval); + 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 + + 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(() => 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..447be17 100644 --- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -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) + + /// 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 async Task ASecondPass_WithFewerRepositoriesSelected_NeverShowsMoreDoneThanThereIsToDo() + { + _lister.Handler = org => [Repo(org + "-one"), Repo(org + "-two"), Repo(org + "-three")]; + WorkspaceViewModel alpha = CreateWorkspace("alpha"); + var status = new SyncAllStatus(); + _coordinator.Status = status; + await _coordinator.RunAsync([alpha], CancellationToken.None); // three of three + alpha.Repos[1].IsSelected = false; + alpha.Repos[2].IsSelected = false; + var fractions = new List(); + var said = new List(); + status.PropertyChanged += (_, e) => + { + if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Fraction), StringComparison.Ordinal)) + { + fractions.Add(status.Fraction); + } + else if(string.Equals(e.PropertyName, nameof(SyncAllStatus.Message), StringComparison.Ordinal)) + { + said.Add(status.Message); + } + }; + + await _coordinator.RunAsync([alpha], CancellationToken.None); // one of one; the run before left 3 of 3 behind + + Assert.All(fractions, fraction => Assert.True(fraction is null or (>= 0 and <= 1), $"fraction {fraction}")); + Assert.DoesNotContain(said, message => message.Contains("3 of 1", StringComparison.Ordinal)); + Assert.Contains("alpha: 1 of 1 repository.", said, StringComparer.Ordinal); + } + + + + [Fact] + public void ACancelRequest_BeforeTheFirstAccountHasStarted_IsRemembered() + { + 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/TableOrderTests.cs b/gclo.Engine.Tests/TableOrderTests.cs new file mode 100644 index 0000000..83fa727 --- /dev/null +++ b/gclo.Engine.Tests/TableOrderTests.cs @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; +using static gclo.Engine.Tests.GitTestHelpers; + + +namespace gclo.Engine.Tests; + + +/// +/// The repository table's default order (#168), checked against the rule as it is +/// written here, not against the view model's own comparison: while a sync runs, the +/// rows in flight (the one that started first on top), then the failed ones and the +/// finished ones (the latest first in each), then the ones waiting, by name; with no +/// sync running, the rows in flight, the failed ones, and then the rest, by name. +/// Every report is made on the test's thread through the view model's run seam, so +/// each step is checked the moment it happened. +/// +public sealed class TableOrderTests : IDisposable +{ + private readonly FakeRepositoryLister _lister = new(); + + private readonly string _folder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + private readonly List _made = []; + + /// What the test knows about each row: its status, and when it last started or finished. + private readonly Dictionary _rows = new(StringComparer.Ordinal); + + private TaskCompletionSource? _run; + + private IProgress? _progress; + + private List _runNames = []; + + private long _clock; + + + + public void Dispose() + { + foreach(WorkspaceViewModel vm in _made) + { + vm.Dispose(); + } + TryDeleteDirectory(_folder); + } + + + + /// Hands each report to the view model at once, on the reporting (test) thread. + private sealed class Inline(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + + + /// A loaded workspace whose sync runs are driven by the test, report by report. + private async Task LoadAsync(params string[] names) + { + _lister.Repositories = [.. names.Select(n => Repo(n))]; + var vm = new WorkspaceViewModel( + _lister, new FakeGitClient(), new FakeOrganizationLister(), + handler => new Inline(handler), TimeSpan.FromMilliseconds(1), new NullActivityLog(), wsl: new FakeWslCloner()); + _made.Add(vm); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = _folder; + vm.SyncRunner = (_, repos, progress, _) => + { + _run = new TaskCompletionSource(); + _progress = progress; + _runNames = [.. repos.Select(r => r.Name)]; + foreach(string name in _runNames) + { + // The run takes its rows back to waiting; when they last finished stays as it was. + _rows[name] = (SyncStatus.Queued, _rows[name].Activity); + progress.Report(new RepoProgress(name, SyncStatus.Queued)); + } + return _run.Task; + }; + await vm.LoadReposCommand.ExecuteAsync(null); + foreach(string name in names) + { + _rows[name] = (SyncStatus.Queued, 0); + } + return vm; + } + + + + private void Report(string name, SyncStatus status) + { + if(_rows[name].Status != status) + { + _rows[name] = (status, ++_clock); + } + _progress!.Report(new RepoProgress(name, status, status == SyncStatus.Failed ? "boom" : null)); + } + + + + /// Ends the run in flight; rows that never finished are reported canceled, as the engine does. + private void EndRun() + { + foreach(string name in _runNames) + { + if(_rows[name].Status is not (SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled)) + { + Report(name, SyncStatus.Canceled); + } + } + TaskCompletionSource run = _run!; + _run = null; + run.SetResult(new SyncSummary(_runNames.Count, 0, 0, 0, 0, WasCanceled: false)); + } + + + + /// The run in flight stops on an error, with its rows left as they are. + private void FailRun() + { + TaskCompletionSource run = _run!; + _run = null; + run.SetException(new InvalidOperationException("the run failed")); + } + + + + /// The rule, written out: which names the table shows, in which order. + private string[] Expected(RepoFilter filter = RepoFilter.All) + { + bool running = _run is not null; + + int Group(SyncStatus status) => status switch + { + SyncStatus.Cloning or SyncStatus.Pulling => 0, + SyncStatus.Failed => 1, + SyncStatus.Done or SyncStatus.Canceled when running => 2, + _ => 3, + }; + + long WithinGroup((SyncStatus Status, long Activity) row) => Group(row.Status) switch + { + 0 => row.Activity, // the one that started first stays on top + 1 or 2 when running => -row.Activity, // the latest first + _ => 0, // by name + }; + + return + [ + .. _rows + .Where(row => filter switch + { + RepoFilter.Active => row.Value.Status is SyncStatus.Cloning or SyncStatus.Pulling, + RepoFilter.Failed => row.Value.Status == SyncStatus.Failed, + RepoFilter.Pending => row.Value.Status == SyncStatus.Queued, + _ => true, + }) + .OrderBy(row => Group(row.Value.Status)) + .ThenBy(row => WithinGroup(row.Value)) + .ThenBy(row => row.Key, StringComparer.OrdinalIgnoreCase) + .Select(row => row.Key), + ]; + } + + + + private static string[] Table(WorkspaceViewModel vm) => [.. vm.FilteredRepos.Select(r => r.Name)]; + + + + [Fact] + public async Task SeveralInFlight_OneFinishing_OneFailing_OneCanceled_EachRowIsWhereTheRulePutsIt() + { + WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie", "delta", "echo"); + Task sync = vm.SyncCommand.ExecuteAsync(null); + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm)); + + Report("bravo", SyncStatus.Cloning); + Report("alpha", SyncStatus.Pulling); + Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], Table(vm)); // bravo started first: it stays on top + + Report("bravo", SyncStatus.Done); // moves down, below the one still in flight + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm)); + + Report("echo", SyncStatus.Cloning); // from the last place to the end of the rows in flight + Assert.Equal(["alpha", "echo", "bravo", "charlie", "delta"], Table(vm)); + + Report("alpha", SyncStatus.Failed); // failed: below the rows in flight, above the finished + Assert.Equal(["echo", "alpha", "bravo", "charlie", "delta"], Table(vm)); + + Report("echo", SyncStatus.Canceled); // finished after bravo: above it + Assert.Equal(["alpha", "echo", "bravo", "charlie", "delta"], Table(vm)); + + Report("delta", SyncStatus.Cloning); + Report("delta", SyncStatus.Done); // the latest of the finished + Assert.Equal(["alpha", "delta", "echo", "bravo", "charlie"], Table(vm)); + Assert.Equal(Expected(), Table(vm)); + + EndRun(); // charlie never ran: canceled + await sync; + + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Table(vm)); // the failed one first, then by name + Assert.Equal(SyncStatus.Failed, vm.FilteredRepos[0].Status); + } + + + + [Fact] + public async Task ASecondRun_KeepsWhatTheFirstFinished_AboveWhatItWaitsFor_AndTheRetriedRowGoesOnTop() + { + WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie"); + Task first = vm.SyncCommand.ExecuteAsync(null); + Report("alpha", SyncStatus.Cloning); + Report("alpha", SyncStatus.Done); + Report("bravo", SyncStatus.Cloning); + Report("bravo", SyncStatus.Failed); + Report("charlie", SyncStatus.Cloning); + Report("charlie", SyncStatus.Done); + EndRun(); + await first; + Assert.Equal(["bravo", "alpha", "charlie"], Table(vm)); + + Task retry = vm.RetryFailedCommand.ExecuteAsync(null); // bravo alone + Assert.Equal(["bravo"], _runNames); + Assert.Equal(["charlie", "alpha", "bravo"], Table(vm)); // finished (the latest first), then the one waiting + + Report("bravo", SyncStatus.Pulling); + Assert.Equal(["bravo", "charlie", "alpha"], Table(vm)); + Report("bravo", SyncStatus.Done); + Assert.Equal(["bravo", "charlie", "alpha"], Table(vm)); // now the latest of the finished + EndRun(); + await retry; + + Assert.Equal(["alpha", "bravo", "charlie"], Table(vm)); + } + + + + [Fact] + public async Task ARunThatStopsOnAnError_LeavesTheTableInTheOrderOfNoRun() + { + WorkspaceViewModel vm = await LoadAsync("alpha", "bravo", "charlie"); + Task sync = vm.SyncCommand.ExecuteAsync(null); + Report("charlie", SyncStatus.Cloning); + Report("charlie", SyncStatus.Done); + Report("bravo", SyncStatus.Cloning); + Report("bravo", SyncStatus.Failed); + Assert.Equal(["bravo", "charlie", "alpha"], Table(vm)); + + FailRun(); + await sync; + + Assert.Equal(RunResultKind.Error, vm.ResultKind); + Assert.Equal(["bravo", "alpha", "charlie"], Table(vm)); + Assert.Equal(Expected(), Table(vm)); + } + + + + /// + /// Many runs of random steps: starts with a random selection, reports in any + /// order the engine could make them, filters, ends and failures. After every step + /// the table is what the rule says, row for row. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + public async Task WhateverHappens_InWhateverOrder_TheTableIsWhatTheRuleSays(int seed) + { + string[] names = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"]; + WorkspaceViewModel vm = await LoadAsync(names); + var random = new Random(seed); + RepoFilter[] filters = [RepoFilter.All, RepoFilter.Active, RepoFilter.Failed, RepoFilter.Pending]; + Task? sync = null; + + for(int step = 0; step < 400; step++) + { + int roll = random.Next(100); + if(roll < 8) + { + vm.Filter = filters[random.Next(filters.Length)]; + } + else if(_run is null) + { + foreach(RepoItemViewModel row in vm.Repos) + { + row.IsSelected = random.Next(10) < 7; + } + vm.Repos[random.Next(vm.Repos.Count)].IsSelected = true; + sync = vm.SyncCommand.ExecuteAsync(null); + } + else if(roll < 14) + { + EndRun(); + await sync!; + } + else if(roll < 17) + { + FailRun(); + await sync!; + } + else + { + string name = _runNames[random.Next(_runNames.Count)]; + SyncStatus next = _rows[name].Status switch + { + SyncStatus.Queued => random.Next(2) == 0 ? SyncStatus.Cloning : SyncStatus.Pulling, + SyncStatus.Cloning or SyncStatus.Pulling => random.Next(10) switch + { + < 6 => SyncStatus.Done, + < 9 => SyncStatus.Failed, + _ => SyncStatus.Canceled, + }, + SyncStatus status => status, // final: the engine reports nothing more for it + }; + if(next != _rows[name].Status) + { + Report(name, next); + } + } + + string[] table = Table(vm); + Assert.True( + Expected(vm.Filter).SequenceEqual(table, StringComparer.Ordinal), + $"seed {seed}, step {step}, filter {vm.Filter}, running {_run is not null}:\n expected {string.Join(", ", Expected(vm.Filter))}\n table {string.Join(", ", table)}"); + } + } +} diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 7134d4f..4d9b682 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -454,41 +454,165 @@ public sealed class WorkspaceViewModelTests : IDisposable - // ---------------------------------------------------------------- active strip + // ---------------------------------------------------------------- the table's default order (#168) - [Fact] - public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds() + /// + /// Waits until the table reads , top to bottom. The table + /// changes on the engine's thread in these tests, a moment after the status that + /// the test waited for: asking for the order once could catch it mid-move. + /// + private static Task TableReadsAsync(WorkspaceViewModel vm, params string[] names) + => WaitUntilAsync( + () => + { + try + { + return vm.FilteredRepos.Select(r => r.Name).SequenceEqual(names, StringComparer.Ordinal); + } + catch(InvalidOperationException) + { + return false; // changed while it was read: look again + } + }, + "the table to read " + string.Join(", ", names)); + + + + /// 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"); + 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 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 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 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 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); + } + + + + // ---------------------------------------------------------------- review of #169 + + [Fact] + public async Task AFolderThatDoesNotExistYet_IsSaidInTheStatusLine_WhenItIsAskedFor() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + + vm.NoteFolderNotThereYet(); + + Assert.Equal($"The folder '{vm.EffectiveTargetRoot}' does not exist yet. The first sync creates it.", vm.StatusText); + } + + + + [Fact] + public async Task ARowInPathRecovery_IsOnTopWhileItRuns_AndBackInItsPlaceAfterwards() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("aardvark"), Repo("alpha"), Repo("charlie")); + await vm.SyncCommand.ExecuteAsync(null); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + byName["aardvark"].Status = SyncStatus.Failed; // a failed row that comes before alpha by name + vm.Filter = RepoFilter.Failed; + vm.Filter = RepoFilter.All; + Assert.Equal(["aardvark", "alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + var inRecovery = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var letGo = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _git.ApplyRecoveryHandler = async (_, _, _) => + { + inRecovery.SetResult(); + await letGo.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + }; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.Apply( + new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)))); + + Task resolve = vm.ResolvePathsCommand.ExecuteAsync(byName["alpha"]); + await inRecovery.Task.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(SyncStatus.Pulling, byName["alpha"].Status); + Assert.Equal(["alpha", "aardvark", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); // in flight: above the failed row + + letGo.SetResult(); + await resolve; + + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); + Assert.Equal(["aardvark", "alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); // the failed row first, then by name + } + + + + [Fact] + public async Task ARowWhoseRecoveryWasOpen_WhenTheTableWasLoadedAgain_DoesNotComeBackAsAGhost() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel old = vm.Repos.Single(r => r.HasPathIssue); + vm.SortCommand.Execute("Name"); // with a column chosen the old row was looked up in the new table's index + vm.RecoveryInteraction = async _ => + { + // The dialog is open; the user reloads the table behind it, then applies. + await vm.LoadReposCommand.ExecuteAsync(null); + return new PathRecoveryDecision.Apply( + new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal))); + }; + + await vm.ResolvePathsCommand.ExecuteAsync(old); + + Assert.DoesNotContain(old, vm.FilteredRepos); + Assert.Equal(["alpha", "bravo"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + Assert.Equal(vm.Repos, vm.FilteredRepos); + } } diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs index 0811e96..6b42cf0 100644 --- a/gclo.UiTests/RepoTableTests.cs +++ b/gclo.UiTests/RepoTableTests.cs @@ -67,8 +67,8 @@ public sealed class RepoTableTests : UiTestBase header.Patterns.Invoke.Pattern.Invoke(); Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort"); - header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over - Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort"); + header.Patterns.Invoke.Pattern.Invoke(); // the third step gives the column up: the table's default order is back + Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the header after a third sort"); } diff --git a/gclo.ViewModels/BatchedRepoProgress.cs b/gclo.ViewModels/BatchedRepoProgress.cs new file mode 100644 index 0000000..72d6d16 --- /dev/null +++ b/gclo.ViewModels/BatchedRepoProgress.cs @@ -0,0 +1,176 @@ +/* + * 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; + } + + // 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(); + } +} 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..4f814b3 --- /dev/null +++ b/gclo.ViewModels/ResponsivenessMonitor.cs @@ -0,0 +1,268 @@ +/* + * 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); + + /// + /// When this much time lies between two looks, the whole process was not running + /// (the machine slept, a debugger held it): the question in flight says nothing + /// about the window and is dropped. + /// + public static readonly TimeSpan PauseAfter = TimeSpan.FromSeconds(2); + + private readonly Func _post; + + private readonly IActivityLog _log; + + 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; + + /// What was running at the latest collected stall. + private string? _collectedActivity; + + /// When last ran; null before the first look. + private long? _lookedAt; + + private volatile string? _activity; + + + + /// 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; + } + + 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(); + } + } + + + + /// 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(_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); +} 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..0245b83 --- /dev/null +++ b/gclo.ViewModels/SyncAllStatus.cs @@ -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; + + +/// +/// 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; + } + + // 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." : ""); + } + + + + /// 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..2481061 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; } @@ -658,11 +660,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable - /// Records that the user opened the target folder from the results bar (#40). + /// Records that the user opened the target folder, from the toolbar or the results bar (#40). public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'."); + /// + /// The user asked for the target folder and it does not exist: says so in the + /// status line. Nothing has been synced into it yet; the first sync creates it. + /// + public void NoteFolderNotThereYet() + { + StatusText = $"The folder '{EffectiveTargetRoot}' does not exist yet. The first sync creates it."; + _log.Info($"Folder '{EffectiveTargetRoot}' was asked for and does not exist yet."); + } + + + /// Asks every row to re-evaluate its theme-dependent presentation (see ). public void RefreshPresentation() { @@ -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 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 +1050,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 +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 + /// 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 +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 /// /// 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 +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 - /// 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 +1518,140 @@ 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) + { + 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(); } /// - /// 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..648c391 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -108,6 +108,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - @@ -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}"> +