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.
122 lines
4.7 KiB
C#
122 lines
4.7 KiB
C#
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<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;
|
|
// 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);
|
|
}
|
|
}
|
|
}
|