feat(ui): add OperationStatus feedback primitive and OperationIndicator control

Foundation for long-running-operation feedback (P0-1). OperationStatus tracks
IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report)
via an injectable TimeProvider, no static timer. OperationIndicator binds a
status to the shared spinner style. Pre-provisions ops.* locale keys for
groups A and C; no ViewModel wired up yet.
This commit is contained in:
Mika Kuns
2026-08-12 08:31:38 +02:00
parent eca16a3506
commit ca65849280
6 changed files with 445 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
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.
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<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<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;
_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);
}
}
}