refactor(claude-do): merge TaskRowViewModel: CanX und DisabledReason aus einer Gate-Met
ClaudeDo-Task: 6c4b27aa-1b5e-4d73-98f5-dcafa3935247
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
|
||||
|
||||
@@ -159,4 +159,76 @@ public class TaskRowContextMenuTests
|
||||
row.IsSelected = isSelected;
|
||||
Assert.Equal(expected, row.ShowRowActions);
|
||||
}
|
||||
|
||||
// Pins down what the gate methods guarantee by construction: each of the five CanX/reason
|
||||
// pairs must agree on every reachable state, not just the handful of examples above. Swept
|
||||
// across the full state matrix (every TaskStatus x PlanningPhase x IsChild/ParentFinalized x
|
||||
// IsManual x HasInteractiveSession x HasQueuedSubtasks x IsRefining) rather than one example
|
||||
// per gate, so a future edit to a gate's condition without touching its Reason branch (or
|
||||
// vice versa) fails loudly instead of silently drifting.
|
||||
public static IEnumerable<object[]> GateStateMatrix()
|
||||
{
|
||||
foreach (var status in Enum.GetValues<TaskStatus>())
|
||||
foreach (var phase in Enum.GetValues<PlanningPhase>())
|
||||
foreach (var isChild in Bools)
|
||||
foreach (var parentFinalized in Bools)
|
||||
foreach (var isManual in Bools)
|
||||
foreach (var hasInteractiveSession in Bools)
|
||||
foreach (var hasQueuedSubtasks in Bools)
|
||||
foreach (var isRefining in Bools)
|
||||
yield return new object[]
|
||||
{
|
||||
status, phase, isChild, parentFinalized, isManual,
|
||||
hasInteractiveSession, hasQueuedSubtasks, isRefining,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly bool[] Bools = { false, true };
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GateStateMatrix))]
|
||||
public void CanX_Agrees_With_DisabledReason_Across_State_Matrix(
|
||||
TaskStatus status, PlanningPhase phase, bool isChild, bool parentFinalized, bool isManual,
|
||||
bool hasInteractiveSession, bool hasQueuedSubtasks, bool isRefining)
|
||||
{
|
||||
var row = new TaskRowViewModel
|
||||
{
|
||||
Id = "t1",
|
||||
Status = status,
|
||||
PlanningPhase = phase,
|
||||
ParentTaskId = isChild ? "parent-id" : null,
|
||||
ParentFinalized = parentFinalized,
|
||||
IsManual = isManual,
|
||||
HasInteractiveSession = hasInteractiveSession,
|
||||
HasQueuedSubtasks = hasQueuedSubtasks,
|
||||
IsRefining = isRefining,
|
||||
};
|
||||
|
||||
Assert.Equal(row.CanSendToQueue, row.SendToQueueDisabledReason is null);
|
||||
Assert.Equal(row.IsRunning, row.CancelDisabledReason is null);
|
||||
Assert.Equal(row.CanRefine, row.RefineDisabledReason is null);
|
||||
|
||||
var canOpenPlanningMenu = row.CanOpenPlanningSession || row.CanResumeOrDiscardPlanning || row.CanFinalizePlanning;
|
||||
Assert.Equal(canOpenPlanningMenu, row.PlanningDisabledReason is null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_NoPath()
|
||||
{
|
||||
var row = MakeRow();
|
||||
Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_Path_Exists()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var row = MakeRow();
|
||||
row.WorktreePath = dir.FullName;
|
||||
Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null);
|
||||
}
|
||||
finally { dir.Delete(true); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user