fix(ui): stop the merge-helper tick from re-ticking unticked candidates

This commit is contained in:
mika kuns
2026-08-11 08:32:52 +02:00
parent 7106bf754f
commit d3ba279b54
2 changed files with 94 additions and 7 deletions
@@ -41,6 +41,10 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade. // Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new(); private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
// Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state
// doesn't cascade over its children's own remembered state.
private bool _suppressCascade;
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new(); public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
[ObservableProperty] private string _scopeLabel = ""; [ObservableProperty] private string _scopeLabel = "";
@@ -70,16 +74,31 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName); ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
} }
// LoadAsync rebuilds every row from scratch, which would wipe the user's ticks out from // LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
// under them on every tick — capture and restore selection by id around the reload. // the user's ticks out from under them on every tick — in BOTH directions. Remembering only
// the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/
// WaitingForReview/Failed all default to ticked), so carry the full state of every row that
// already existed across the reload; only genuinely new rows get the default.
internal async Task ReconcileTickAsync() internal async Task ReconcileTickAsync()
{ {
if (string.IsNullOrEmpty(_listId)) return; if (string.IsNullOrEmpty(_listId)) return;
var selected = Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToHashSet(); var previous = Tasks.ToDictionary(t => t.Id, t => t.IsSelected);
await LoadAsync(); await LoadAsync();
foreach (var t in Tasks)
if (selected.Contains(t.Id)) // Restore without the parent→children cascade: it would overwrite a child's own remembered
t.IsSelected = true; // state with its parent's, undoing exactly the per-child choice being restored.
_suppressCascade = true;
try
{
foreach (var t in Tasks)
if (previous.TryGetValue(t.Id, out var wasSelected))
t.IsSelected = wasSelected;
}
finally
{
_suppressCascade = false;
}
OnPropertyChanged(nameof(CanConfirm)); OnPropertyChanged(nameof(CanConfirm));
} }
@@ -162,7 +181,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
// Ticking/unticking a parent cascades to its children; a child can still be // Ticking/unticking a parent cascades to its children; a child can still be
// deselected on its own afterwards without affecting the parent's checkbox — // deselected on its own afterwards without affecting the parent's checkbox —
// ClaudeDo has no indeterminate/tri-state parent checkbox. // ClaudeDo has no indeterminate/tri-state parent checkbox.
if (sender is MergeHelperTaskRowViewModel row if (!_suppressCascade
&& sender is MergeHelperTaskRowViewModel row
&& _childrenByParentId.TryGetValue(row.Id, out var children)) && _childrenByParentId.TryGetValue(row.Id, out var children))
{ {
foreach (var child in children) child.IsSelected = row.IsSelected; foreach (var child in children) child.IsSelected = row.IsSelected;
@@ -89,6 +89,73 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected);
} }
[Fact]
public async Task ReconcileTick_KeepsBothTicksAndUnticks_AndDefaultsOnlyNewRows()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
// The user's own choices, both directions, against the defaults.
vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = false; // default true -> off
vm.Tasks.Single(t => t.Id == "t-running").IsSelected = true; // default false -> on
await using (var db = NewContext())
{
db.Tasks.Add(new TaskEntity
{
Id = "t-new", ListId = "L1", Title = "arrived later",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 99,
});
await db.SaveChangesAsync();
}
await vm.ReconcileTickAsync();
Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-running").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); // untouched, keeps default
Assert.True(vm.Tasks.Single(t => t.Id == "t-new").IsSelected); // new row gets the default
}
[Fact]
public async Task ReconcileTick_DoesNotCascadeParentStateOverAnUntickedChild()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Id = "parent", ListId = "L1", Title = "parent",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
db.Tasks.Add(new TaskEntity
{
Id = "child1", ListId = "L1", Title = "child1", ParentTaskId = "parent",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1,
});
db.Tasks.Add(new TaskEntity
{
Id = "child2", ListId = "L1", Title = "child2", ParentTaskId = "parent",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2,
});
await db.SaveChangesAsync();
}
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
vm.Tasks.Single(t => t.Id == "child1").IsSelected = false; // parent stays ticked
await vm.ReconcileTickAsync();
Assert.True(vm.Tasks.Single(t => t.Id == "parent").IsSelected);
Assert.False(vm.Tasks.Single(t => t.Id == "child1").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected);
}
[Fact] [Fact]
public async Task Load_PerListScope_FiltersToThatList() public async Task Load_PerListScope_FiltersToThatList()
{ {