refactor(ui): derive TaskRowViewModel CanX/DisabledReason pairs from one gate method

SendToQueue, Cancel, Refine, Planning and OpenWorktree each had a CanX property
and a hand-written negation of it for the DisabledReason tooltip, kept in sync
only by a comment's promise. Replace each pair with a private gate method
returning (Can, Reason) so there is one source of truth per condition, and add
a state-matrix test pinning down the CanX == (Reason == null) invariant that
the old comment only asserted.
This commit is contained in:
Mika Kuns
2026-08-24 09:36:00 +02:00
parent 29171b104b
commit baba921696
2 changed files with 120 additions and 49 deletions
@@ -78,9 +78,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// star, dequeue-X) — a set star and the IsRefining spinner are exempt, they show always.
public bool ShowRowActions => IsHovered || IsSelected;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
public DateTime CreatedAt { get; init; }
@@ -140,11 +137,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// touches task status), so this gate has to live here: queueing a task the user is actively
// hand-editing in an interactive pane would spawn an autonomous run racing it in the same
// worktree.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active
&& !IsManual
&& !HasInteractiveSession;
public bool CanSendToQueue => SendToQueueGate().Can;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren
@@ -155,26 +148,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Context-menu Group 1 reasons: null when the paired CanX is true, otherwise the specific
// blocking condition — shown as a disabled MenuItem's tooltip (ToolTip.ShowOnDisabled="True",
// verified headlessly to actually render on Avalonia 12 — see task A3). Each mirrors the
// negation of its CanX so exactly one branch matches whenever CanX is false.
public string? SendToQueueDisabledReason
// verified headlessly to actually render on Avalonia 12 — see task A3). Each pair (CanX,
// XDisabledReason) is derived from one private "gate" method so there is a single source of
// truth for the condition instead of a CanX expression and a hand-written negation of it.
private (bool Can, string? Reason) SendToQueueGate()
{
get
{
if (CanSendToQueue) return null;
if (IsRunning) return Loc.T("tasks.reasonAlreadyRunning");
if (IsQueued) return Loc.T("tasks.reasonAlreadyQueued");
if (IsWaitingForReview) return Loc.T("tasks.reasonWaitingForReview");
if (HasQueuedSubtasks) return Loc.T("tasks.reasonSubtasksQueued");
if (IsChild && !ParentFinalized) return Loc.T("tasks.reasonPlanNotFinalized");
if (PlanningPhase == PlanningPhase.Active) return Loc.T("tasks.reasonPlanningActive");
if (IsManual) return Loc.T("tasks.manualTip");
if (HasInteractiveSession) return Loc.T("tasks.reasonInteractiveSession");
return null;
}
if (IsRunning) return (false, Loc.T("tasks.reasonAlreadyRunning"));
if (IsQueued) return (false, Loc.T("tasks.reasonAlreadyQueued"));
if (IsWaitingForReview) return (false, Loc.T("tasks.reasonWaitingForReview"));
if (HasQueuedSubtasks) return (false, Loc.T("tasks.reasonSubtasksQueued"));
if (IsChild && !ParentFinalized) return (false, Loc.T("tasks.reasonPlanNotFinalized"));
if (PlanningPhase == PlanningPhase.Active) return (false, Loc.T("tasks.reasonPlanningActive"));
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (HasInteractiveSession) return (false, Loc.T("tasks.reasonInteractiveSession"));
return (true, null);
}
public string? SendToQueueDisabledReason => SendToQueueGate().Reason;
public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning");
// IsRunning is a standalone status property used well beyond this one gate, so it stays as
// is; only the reason text is derived from a gate method for consistency with the others.
private (bool Can, string? Reason) CancelGate() =>
IsRunning ? (true, null) : (false, Loc.T("tasks.reasonNotRunning"));
public string? CancelDisabledReason => CancelGate().Reason;
// "Open quick session" has no precondition today (see A1's file header) — kept as a
// placeholder so the menu wiring is uniform; always null until a real gate exists.
@@ -182,36 +177,40 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// The row keeps a merged/discarded worktree's recorded Path, so a non-empty string is not
// proof the folder is still there — same on-disk check the worktrees overview and the merge
// section do before offering "open". Only read when the context menu is built, not per row.
public bool CanOpenWorktree =>
!string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree");
public string? RefineDisabledReason
// section do before offering "open". Only read when the context menu is built, not per row;
// each call to the gate does exactly one Directory.Exists (no caching).
private (bool Can, string? Reason) OpenWorktreeGate()
{
get
{
if (CanRefine) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsRefining) return Loc.T("tasks.reasonRefining");
if (PlanningPhase != PlanningPhase.None) return Loc.T("tasks.reasonIsPlanningParent");
return Loc.T("tasks.reasonNotIdle");
}
var can = !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
return (can, can ? null : Loc.T("tasks.reasonNoWorktree"));
}
public bool CanOpenWorktree => OpenWorktreeGate().Can;
public string? OpenWorktreeDisabledReason => OpenWorktreeGate().Reason;
private (bool Can, string? Reason) RefineGate()
{
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (IsRefining) return (false, Loc.T("tasks.reasonRefining"));
if (PlanningPhase != PlanningPhase.None) return (false, Loc.T("tasks.reasonIsPlanningParent"));
if (Status != TaskStatus.Idle) return (false, Loc.T("tasks.reasonNotIdle"));
return (true, null);
}
public bool CanRefine => RefineGate().Can;
public string? RefineDisabledReason => RefineGate().Reason;
// Gates the "Planning" submenu header — null unless Open/Resume-or-Discard/Finalize would
// all be hidden, i.e. the submenu would otherwise open empty.
public string? PlanningDisabledReason
// all be hidden, i.e. the submenu would otherwise open empty. The three CanX below stay
// standalone properties (bindings/tests use them individually); the gate only derives the
// combined reason.
private (bool Can, string? Reason) PlanningGate()
{
get
{
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsChild) return Loc.T("tasks.reasonIsChildTask");
if (PlanningPhase == PlanningPhase.Finalized) return Loc.T("tasks.reasonPlanAlreadyFinalized");
return Loc.T("tasks.reasonNotIdle");
}
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return (true, null);
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (IsChild) return (false, Loc.T("tasks.reasonIsChildTask"));
if (PlanningPhase == PlanningPhase.Finalized) return (false, Loc.T("tasks.reasonPlanAlreadyFinalized"));
return (false, Loc.T("tasks.reasonNotIdle"));
}
public string? PlanningDisabledReason => PlanningGate().Reason;
public bool HasSchedule => ScheduledFor.HasValue;
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in