diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index dfdd95ae..74b8eb62 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -411,7 +411,9 @@ "selectNone": "Keine auswählen", "empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.", "columnTask": "AUFGABE", - "columnStatus": "STATUS" + "columnStatus": "STATUS", + "orphanHint": "ELTERNTEIL NICHT IN AUSWAHL", + "orphanHintTip": "Das Elternteil dieser Unteraufgabe wird hier nicht angeboten (bereits erledigt oder abgebrochen), daher steht sie einzeln." }, "unfinishedPlanning": { "title": "UNVOLLENDETE PLANUNGSSITZUNG", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index a4220211..91aba808 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -411,7 +411,9 @@ "selectNone": "Select none", "empty": "No tasks to hand over — everything is done or cancelled.", "columnTask": "TASK", - "columnStatus": "STATUS" + "columnStatus": "STATUS", + "orphanHint": "PARENT NOT IN SELECTION", + "orphanHintTip": "This subtask's parent isn't offered here (already done or cancelled), so it's listed on its own." }, "unfinishedPlanning": { "title": "UNFINISHED PLANNING SESSION", diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs index 67c7bb86..6ab423aa 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs @@ -14,6 +14,16 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase public required string Id { get; init; } public required string Title { get; init; } public required string StatusText { get; init; } + public string? ParentTaskId { get; init; } + + /// True when the parent is also a candidate and rendered directly above this row. + public bool IsChild { get; init; } + + /// + /// True when is set but the parent isn't a candidate itself + /// (e.g. already Done/Cancelled) — the row still renders top-level, flagged with a hint. + /// + public bool IsOrphanChild { get; init; } [ObservableProperty] private bool _isSelected; } @@ -28,6 +38,9 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase private readonly IDbContextFactory _dbFactory; private string _listId = ""; + // Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade. + private Dictionary> _childrenByParentId = new(); + public ObservableCollection Tasks { get; } = new(); [ObservableProperty] private string _scopeLabel = ""; @@ -51,6 +64,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase { foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged; Tasks.Clear(); + _childrenByParentId = new(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var candidates = await ctx.Tasks.AsNoTracking() @@ -59,21 +73,56 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase // Manual tasks are reminders the user owns — never offer them to the handler. .Where(t => !t.IsManual) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) - .Select(t => new { t.Id, t.Title, t.Status }) + .Select(t => new { t.Id, t.Title, t.Status, t.ParentTaskId }) .ToListAsync(ct); - foreach (var c in candidates) + var candidateIds = candidates.Select(c => c.Id).ToHashSet(); + bool ParentIsCandidate(string? parentId) => + !string.IsNullOrEmpty(parentId) && candidateIds.Contains(parentId); + + var childrenByParent = candidates + .Where(c => ParentIsCandidate(c.ParentTaskId)) + .GroupBy(c => c.ParentTaskId!) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Hierarchy-ordered walk, same idea as TasksIslandViewModel.Regroup: top-level rows + // (including orphaned children whose parent isn't a candidate) interleaved with their + // visible children, preserving the SortOrder/CreatedAt order within each level. + var emitted = new HashSet(); + var ordered = new List<(string Id, string Title, TaskStatus Status, string? ParentTaskId)>(); + void Emit(string id, string title, TaskStatus status, string? parentTaskId) { + if (!emitted.Add(id)) return; + ordered.Add((id, title, status, parentTaskId)); + if (childrenByParent.TryGetValue(id, out var kids)) + foreach (var kid in kids) + Emit(kid.Id, kid.Title, kid.Status, kid.ParentTaskId); + } + foreach (var c in candidates.Where(c => !ParentIsCandidate(c.ParentTaskId))) + Emit(c.Id, c.Title, c.Status, c.ParentTaskId); + + foreach (var c in ordered) + { + var isChild = ParentIsCandidate(c.ParentTaskId); var row = new MergeHelperTaskRowViewModel { Id = c.Id, Title = c.Title, StatusText = c.Status.ToString(), + ParentTaskId = c.ParentTaskId, + IsChild = isChild, + IsOrphanChild = !isChild && !string.IsNullOrEmpty(c.ParentTaskId), IsSelected = IsTickedByDefault(c.Status), }; row.PropertyChanged += OnRowChanged; Tasks.Add(row); } + + _childrenByParentId = Tasks + .Where(r => r.IsChild) + .GroupBy(r => r.ParentTaskId!) + .ToDictionary(g => g.Key, g => g.ToList()); + OnPropertyChanged(nameof(HasTasks)); OnPropertyChanged(nameof(CanConfirm)); } @@ -85,8 +134,18 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase private void OnRowChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected)) - OnPropertyChanged(nameof(CanConfirm)); + if (e.PropertyName != nameof(MergeHelperTaskRowViewModel.IsSelected)) return; + + // 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 + && _childrenByParentId.TryGetValue(row.Id, out var children)) + { + foreach (var child in children) child.IsSelected = row.IsSelected; + } + + OnPropertyChanged(nameof(CanConfirm)); } [RelayCommand] diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml index 78cd2a76..df6fa27b 100644 --- a/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml @@ -38,10 +38,10 @@ - - - + + @@ -52,14 +52,27 @@ - - + + + + + - - + + + + + + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs index 1558103d..6ac46bc0 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs @@ -162,6 +162,105 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable Assert.True(closed); } + [Fact] + public async Task Load_NestsChildUnderParent_InOrder() + { + 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 = "sibling", ListId = "L1", Title = "sibling", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1 }); + db.Tasks.Add(new TaskEntity { Id = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, ParentTaskId = "parent" }); + await db.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + + // Child is nested directly after its parent, ahead of the later sibling. + Assert.Equal(new[] { "parent", "child", "sibling" }, vm.Tasks.Select(t => t.Id)); + Assert.True(vm.Tasks.Single(t => t.Id == "child").IsChild); + Assert.False(vm.Tasks.Single(t => t.Id == "child").IsOrphanChild); + Assert.False(vm.Tasks.Single(t => t.Id == "parent").IsChild); + } + + [Fact] + public async Task Load_ChildOfFilteredOutParent_RendersTopLevelAsOrphan() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity { Id = "parent-done", ListId = "L1", Title = "parent", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + db.Tasks.Add(new TaskEntity { Id = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent-done" }); + await db.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + + // The parent is Done and filtered out; the child must not disappear, and must be + // flagged as an orphan instead of rendering as a (broken) child. + Assert.Single(vm.Tasks); + var row = vm.Tasks[0]; + Assert.Equal("child", row.Id); + Assert.False(row.IsChild); + Assert.True(row.IsOrphanChild); + } + + [Fact] + public async Task Toggle_Parent_CascadesSelectionToChildren() + { + 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", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent" }); + db.Tasks.Add(new TaskEntity { Id = "child2", ListId = "L1", Title = "child2", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, ParentTaskId = "parent" }); + await db.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + vm.SelectNoneCommand.Execute(null); + Assert.All(vm.Tasks, t => Assert.False(t.IsSelected)); + + vm.Tasks.Single(t => t.Id == "parent").IsSelected = true; + Assert.True(vm.Tasks.Single(t => t.Id == "child1").IsSelected); + Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected); + + // A child can be individually deselected afterwards without touching the parent. + vm.Tasks.Single(t => t.Id == "child1").IsSelected = false; + 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); + + vm.Tasks.Single(t => t.Id == "parent").IsSelected = false; + Assert.False(vm.Tasks.Single(t => t.Id == "child2").IsSelected); + } + + [Fact] + public async Task Confirm_ReturnsParentsBeforeChildren() + { + 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 = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent" }); + await db.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + vm.ConfirmCommand.Execute(null); + + var result = await vm.Result.Task; + Assert.Equal(new[] { "parent", "child" }, result); + } + [Fact] public async Task Load_NoCandidates_HasTasksFalse_CannotConfirm() {