refactor(ui): flatten TasksIsland's task list into one virtualized ListBox

Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.

SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.

Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
This commit is contained in:
mika kuns
2026-08-10 16:18:55 +02:00
parent 514d6111fe
commit ec6c230822
6 changed files with 369 additions and 139 deletions
@@ -14,6 +14,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>A group header entry in <see cref="TasksIslandViewModel.Rows"/> — the flat list's
/// other union member alongside <see cref="TaskRowViewModel"/>. Instances are persistent (one per
/// section, mutated in place by <see cref="TasksIslandViewModel.Regroup"/>) so an unchanged header
/// keeps its identity across a reconcile.</summary>
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<ClaudeDoDbContext> _dbFactory;
@@ -57,9 +70,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
/// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
/// (<see cref="HeaderRow"/>) interleaved with visible <see cref="TaskRowViewModel"/> rows, in
/// display order. Reconciled granularly by <see cref="Regroup"/> — never Clear+Add.</summary>
public ObservableCollection<object> 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;
@@ -115,6 +134,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)
{
@@ -138,7 +158,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)
@@ -309,9 +328,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;
@@ -459,7 +476,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<object>();
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<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> 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();
@@ -479,7 +543,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);
@@ -506,18 +570,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()
@@ -615,13 +668,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<object>(Rows, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
@@ -639,10 +692,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
await db.SaveChangesAsync();
}
private static void MoveWithinCollection(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
TaskRowViewModel source,
TaskRowViewModel target,
private static void MoveWithinCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> coll,
T source,
T target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
@@ -660,11 +713,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<TaskRowViewModel> dst,
List<TaskRowViewModel> target)
private static void SyncCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> dst,
List<T> target)
{
var keep = new HashSet<TaskRowViewModel>(target);
var keep = new HashSet<T>(target);
for (int i = dst.Count - 1; i >= 0; i--)
if (!keep.Contains(dst[i]))
dst.RemoveAt(i);
@@ -680,12 +733,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
}
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? 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;
}
/// <summary>
@@ -778,11 +840,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)
{
@@ -1058,6 +1121,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]