fix(ui): stop the merge-helper tick from re-ticking unticked candidates
This commit is contained in:
@@ -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.
|
||||
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();
|
||||
|
||||
[ObservableProperty] private string _scopeLabel = "";
|
||||
@@ -70,16 +74,31 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||
}
|
||||
|
||||
// LoadAsync rebuilds every row from scratch, which would wipe the user's ticks out from
|
||||
// under them on every tick — capture and restore selection by id around the reload.
|
||||
// LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
|
||||
// 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()
|
||||
{
|
||||
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();
|
||||
|
||||
// Restore without the parent→children cascade: it would overwrite a child's own remembered
|
||||
// state with its parent's, undoing exactly the per-child choice being restored.
|
||||
_suppressCascade = true;
|
||||
try
|
||||
{
|
||||
foreach (var t in Tasks)
|
||||
if (selected.Contains(t.Id))
|
||||
t.IsSelected = true;
|
||||
if (previous.TryGetValue(t.Id, out var wasSelected))
|
||||
t.IsSelected = wasSelected;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressCascade = false;
|
||||
}
|
||||
|
||||
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
|
||||
// deselected on its own afterwards without affecting the parent's 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))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task Load_PerListScope_FiltersToThatList()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user