diff --git a/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs b/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs new file mode 100644 index 00000000..9af97f28 --- /dev/null +++ b/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using ClaudeDo.Ui.ViewModels.Islands; + +namespace ClaudeDo.Ui.Converters; + +// Drives ListBoxItem.Focusable for TasksIslandView's flat Rows list: a HeaderRow container must +// not be a tab stop, a TaskRowViewModel container must behave like any other row. +public class NotHeaderRowConverter : IValueConverter +{ + public static NotHeaderRowConverter Instance { get; } = new(); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is not HeaderRow; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 2d86ce68..967a2e67 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -15,6 +15,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels.Islands; +/// A group header entry in — the flat list's +/// other union member alongside . Instances are persistent (one per +/// section, mutated in place by ) so an unchanged header +/// keeps its identity across a reconcile. +public sealed partial class HeaderRow : ViewModelBase +{ + [ObservableProperty] private string _label = ""; + [ObservableProperty] private int _count; + public bool IsOverdue { get; init; } + public IRelayCommand? ActionCommand { get; init; } + public bool HasAction => ActionCommand is not null; +} + public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable { private readonly IDbContextFactory _dbFactory; @@ -60,9 +73,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } public ObservableCollection Items { get; } = new(); - public ObservableCollection OverdueItems { get; } = new(); - public ObservableCollection OpenItems { get; } = new(); - public ObservableCollection CompletedItems { get; } = new(); + + /// Flat, virtualization-friendly view of : group headers + /// () interleaved with visible rows, in + /// display order. Reconciled granularly by — never Clear+Add. + public ObservableCollection Rows { get; } = new(); + + private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true }; + private readonly HeaderRow _openHeaderRow = new(); + private readonly HeaderRow _completedHeaderRow; [ObservableProperty] private string _newTaskTitle = ""; [ObservableProperty] private TaskRowViewModel? _selectedTask; @@ -118,6 +137,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable { _dbFactory = dbFactory; _worker = worker; + _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand }; CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); if (_worker is not null) { @@ -145,7 +165,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable { CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); foreach (var row in Items) row.RefreshLocalized(); - foreach (var row in CompletedItems) row.RefreshLocalized(); } private async void OnWorkerListUpdated(string listId) @@ -316,9 +335,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable _currentList.PropertyChanged += OnCurrentListPropertyChanged; Items.Clear(); - OverdueItems.Clear(); - OpenItems.Clear(); - CompletedItems.Clear(); + Rows.Clear(); HasOverdue = false; HasOpen = false; HasCompleted = false; @@ -466,7 +483,54 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable r.IsExpanded = saved; } - // Build hierarchy-aware flat list: top-level rows interleaved with visible children. + var (overdue, open, completed) = ClassifyItems(); + + HasOverdue = overdue.Count > 0; + HasOpen = open.Count > 0; + HasCompleted = completed.Count > 0; + ShowOpenLabel = HasOpen && HasOverdue; + CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count); + + // Build the flat Rows target: header + rows per section, omitting a section entirely + // when it has nothing to show (no header, no rows) instead of hiding it via IsVisible. + var target = new List(); + if (HasOverdue) + { + _overdueHeaderRow.Label = Loc.T("tasks.overdue"); + _overdueHeaderRow.Count = overdue.Count; + target.Add(_overdueHeaderRow); + target.AddRange(overdue); + } + if (HasOpen) + { + if (ShowOpenLabel) + { + _openHeaderRow.Label = Loc.T("tasks.tasks"); + _openHeaderRow.Count = open.Count; + target.Add(_openHeaderRow); + } + target.AddRange(open); + } + if (HasCompleted && IsShowingCompleted) + { + _completedHeaderRow.Label = CompletedHeader; + _completedHeaderRow.Count = completed.Count; + target.Add(_completedHeaderRow); + target.AddRange(completed); + } + + // Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling + // a parent only touches its own child rows — the ListBox keeps every unchanged container + // instead of tearing the whole list down on a Reset. + SyncCollection(Rows, target); + } + + // Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children, + // orphans flagged so they render flat) and classifies it into Overdue/Open/Completed, exactly + // as Regroup renders it. Shared with callers that need "every completed row" independent of + // Rows/IsShowingCompleted (e.g. ClearCompletedAsync). + private (List Overdue, List Open, List Completed) ClassifyItems() + { // Items is already ordered by SortOrder from the DB query. // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level. var visibleIds = Items.Select(r => r.Id).ToHashSet(); @@ -486,7 +550,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (!emitted.Add(parent.Id)) continue; flat.Add(parent); // Also expand for Done parents so their (Done) children reach the classification - // loop and land in CompletedItems alongside the parent. + // loop and land alongside the parent in Completed. if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded) { var children = Items.Where(r => r.ParentTaskId == parent.Id); @@ -513,18 +577,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable open.Add(r); } - // Reconcile the bound collections in place (granular Insert/Move/Remove) rather than - // Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl - // keeps every unchanged container instead of tearing the whole list down on a Reset. - SyncCollection(OverdueItems, overdue); - SyncCollection(OpenItems, open); - SyncCollection(CompletedItems, completed); - - HasOverdue = OverdueItems.Count > 0; - HasOpen = OpenItems.Count > 0; - HasCompleted = CompletedItems.Count > 0; - ShowOpenLabel = HasOpen && HasOverdue; - CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count); + return (overdue, open, completed); } private void UpdateSubtitle() @@ -622,13 +675,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild. MoveWithinCollection(Items, source, target, placeBelow); - // Apply the same move in whichever section the row lives in. + // Apply the same move in Rows, but only when both rows live in the same section. // Reorder never changes which section (Open/Overdue/Completed) a row belongs to — // that's determined by Done flag and ScheduledFor date, not drag-drop. var sourceSection = SectionFor(source); var targetSection = SectionFor(target); if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection)) - MoveWithinCollection(sourceSection, source, target, placeBelow); + MoveWithinCollection(Rows, source, target, placeBelow); var listId = _currentList.Id["user:".Length..]; var orderedIds = Items.Select(i => i.Id).ToList(); @@ -646,10 +699,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable await db.SaveChangesAsync(); } - private static void MoveWithinCollection( - System.Collections.ObjectModel.ObservableCollection coll, - TaskRowViewModel source, - TaskRowViewModel target, + private static void MoveWithinCollection( + System.Collections.ObjectModel.ObservableCollection coll, + T source, + T target, bool placeBelow) { var srcIdx = coll.IndexOf(source); @@ -667,11 +720,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // Reconcile a bound collection toward a target order using granular Remove/Move/Insert, // so unchanged rows keep their containers (no Reset-driven full re-render). - private static void SyncCollection( - System.Collections.ObjectModel.ObservableCollection dst, - List target) + private static void SyncCollection( + System.Collections.ObjectModel.ObservableCollection dst, + List target) { - var keep = new HashSet(target); + var keep = new HashSet(target); for (int i = dst.Count - 1; i >= 0; i--) if (!keep.Contains(dst[i])) dst.RemoveAt(i); @@ -687,12 +740,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } } - private System.Collections.ObjectModel.ObservableCollection? SectionFor(TaskRowViewModel row) + // Marker returned for a row that sits in the flat Open section before any header — Open is the + // only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel + // false). Distinct from null, which means "row not found in Rows at all". + private static readonly object UnlabeledSection = new(); + + // A row's section is read off the nearest preceding HeaderRow in Rows rather than + // re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered + // (including planning-parent edge cases the classification alone can't reconstruct). + private object? SectionFor(TaskRowViewModel row) { - if (OverdueItems.Contains(row)) return OverdueItems; - if (OpenItems.Contains(row)) return OpenItems; - if (CompletedItems.Contains(row)) return CompletedItems; - return null; + var idx = Rows.IndexOf(row); + if (idx < 0) return null; + for (int i = idx - 1; i >= 0; i--) + if (Rows[i] is HeaderRow header) return header; + return UnlabeledSection; } /// @@ -785,11 +847,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [RelayCommand] private async Task ClearCompletedAsync() { - if (CompletedItems.Count == 0) return; + var (_, _, completed) = ClassifyItems(); + if (completed.Count == 0) return; // Delete children before parents so the parent-child FK (Restrict) doesn't // block removing a completed planning parent together with its done children. - var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList(); + var toDelete = completed.OrderByDescending(r => r.IsChild).ToList(); if (ConfirmAsync is not null) { @@ -1065,6 +1128,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [RelayCommand] private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted; + // The completed section is only emitted into Rows while the toggle is on — flipping it + // doesn't change grouping/order data, so a full reload isn't needed, just a re-emit. + partial void OnIsShowingCompletedChanged(bool value) => Regroup(); + public event EventHandler? OpenListSettingsRequested; [RelayCommand] diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml index c5b5fff1..30261c92 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands" - xmlns:converters="using:Avalonia.Data.Converters" + xmlns:uiconverters="using:ClaudeDo.Ui.Converters" xmlns:loc="using:ClaudeDo.Ui.Localization" x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView" x:DataType="vm:TasksIslandViewModel"> @@ -89,82 +89,64 @@ Command="{Binding OpenNotesCommand}" Content="{loc:Tr tasks.notesPinnedRow}"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs index f9e43757..85764034 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -62,19 +61,14 @@ public partial class TasksIslandView : UserControl }; } - // Bring the selected row into view — the task list is a plain ItemsControl with no - // built-in selection scrolling, so a programmatic select (e.g. Mission Control's - // "Open in app") would otherwise highlight a row that stays off-screen. + // Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in + // app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is + // virtualization-aware (it realizes the container as needed); a visual-tree search for an + // existing Button, as this used to do, only finds rows already on screen. private void ScrollSelectedIntoView() { if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return; - Dispatcher.UIThread.Post(() => - { - var match = this.GetVisualDescendants() - .OfType