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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user