using System.Threading; using CommunityToolkit.Mvvm.ComponentModel; namespace ClaudeDo.Ui.Services; /// Feedback primitive for a single long-running operation behind a [RelayCommand]. Multiple /// independent instances per ViewModel are expected (one per command) — there is no shared or /// static state, which is exactly what a static DispatcherTimer would introduce and the cause of /// the order-dependent flakiness in Ui.Tests. /// /// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other /// three write their observable properties inline on the calling thread, so an off-thread caller /// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its /// awaits resume on the UI thread); wrapping the call in Task.Run breaks it. public sealed partial class OperationStatus : ObservableObject { private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300); private static readonly TimeSpan StallThreshold = TimeSpan.FromSeconds(60); private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1); private readonly TimeProvider _time; private readonly Action _postToUiThread; private ITimer? _tickTimer; private DateTimeOffset _startedAt; private DateTimeOffset _lastReportAt; private int _generation; [ObservableProperty] private bool _isRunning; [ObservableProperty] private bool _showIndicator; [ObservableProperty] private string? _label; [ObservableProperty] private string _elapsed = "00:00"; [ObservableProperty] private bool _isStalled; public OperationStatus(TimeProvider? timeProvider = null) : this(timeProvider, null) { } /// Test-only seam. Production always marshals the threadpool timer callback onto the /// dispatcher; tests inject a synchronous poster so they can drive a fake clock without /// bootstrapping a real Avalonia dispatcher. internal OperationStatus(TimeProvider? timeProvider, Action? postToUiThread) { _time = timeProvider ?? TimeProvider.System; _postToUiThread = postToUiThread ?? (action => Avalonia.Threading.Dispatcher.UIThread.Post(action)); } /// Starts the operation; Dispose (even via an exception unwinding a `using` block) ends it. public IDisposable Begin(string label) { _tickTimer?.Dispose(); var generation = ++_generation; _startedAt = _time.GetUtcNow(); _lastReportAt = _startedAt; Label = label; Elapsed = FormatElapsed(TimeSpan.Zero); ShowIndicator = false; IsStalled = false; IsRunning = true; _tickTimer = _time.CreateTimer(_ => OnTick(generation), null, GracePeriod, TickInterval); return new EndOnDispose(this, generation); } /// Overwrites the label mid-flight and resets the stall clock — call this whenever the /// operation's phase changes so a normally multi-minute step doesn't report itself as stuck. public void Report(string label) { Label = label; _lastReportAt = _time.GetUtcNow(); IsStalled = false; } private void OnTick(int generation) => _postToUiThread(() => { if (generation != _generation) return; var now = _time.GetUtcNow(); Elapsed = FormatElapsed(now - _startedAt); if (!ShowIndicator && now - _startedAt >= GracePeriod) ShowIndicator = true; IsStalled = now - _lastReportAt >= StallThreshold; }); private void End(int generation) { if (generation != _generation) return; // Retire the generation *before* touching the flags. The timer callback fires on a // threadpool thread and only posts its UI work, so a tick that fired while the dispatcher // was busy can drain *after* End — with the generation still current it would set // ShowIndicator back to true on an already-finished operation, and since the timer is gone // by then no later tick ever clears it. That is the frozen spinner. _generation++; _tickTimer?.Dispose(); _tickTimer = null; IsRunning = false; ShowIndicator = false; IsStalled = false; } private static string FormatElapsed(TimeSpan elapsed) => (elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed).ToString(@"mm\:ss"); private sealed class EndOnDispose : IDisposable { private OperationStatus? _owner; private readonly int _generation; public EndOnDispose(OperationStatus owner, int generation) { _owner = owner; _generation = generation; } public void Dispose() { var owner = _owner; _owner = null; owner?.End(_generation); } } }