fix(ui): unfreeze the operation spinner and attribute rebind churn

Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
This commit is contained in:
mika kuns
2026-08-12 13:50:05 +02:00
parent b9827eac01
commit 589b9e75f3
19 changed files with 198 additions and 38 deletions
@@ -179,6 +179,30 @@ public class OperationStatusTests
Assert.False(status.ShowIndicator);
}
[Fact]
public void Ticks_that_drain_after_the_operation_ended_do_not_resurrect_the_indicator()
{
// The frozen-spinner bug, measured in the running app: the UI thread is blocked for ~2.4s
// while the main window is built. The tick timer fires on a threadpool thread meanwhile and
// only *posts* its UI work, so three ticks queued up, End ran first — and the queued ticks
// then set ShowIndicator back to true. The timer was already disposed, so nothing ever
// cleared it again. A poster that queues instead of running stands in for that blocked
// dispatcher.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var queued = new List<Action>();
var status = new OperationStatus(time, queued.Add);
var op = status.Begin("Checking for updates…");
time.Advance(TimeSpan.FromSeconds(3)); // ticks fire, their UI work piles up unprocessed
op.Dispose(); // the operation finishes before the queue drains
Assert.False(status.ShowIndicator);
foreach (var action in queued.ToArray()) action(); // dispatcher catches up
Assert.False(status.ShowIndicator);
Assert.False(status.IsRunning);
}
[Fact]
public void Report_overwrites_the_label()
{