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