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
@@ -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();
}
@@ -14,6 +14,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands; 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 public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; 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> Items { get; } = new();
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new(); /// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new(); /// (<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 string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask; [ObservableProperty] private TaskRowViewModel? _selectedTask;
@@ -115,6 +134,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null) if (_worker is not null)
{ {
@@ -138,7 +158,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
foreach (var row in Items) row.RefreshLocalized(); foreach (var row in Items) row.RefreshLocalized();
foreach (var row in CompletedItems) row.RefreshLocalized();
} }
private async void OnWorkerListUpdated(string listId) private async void OnWorkerListUpdated(string listId)
@@ -309,9 +328,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_currentList.PropertyChanged += OnCurrentListPropertyChanged; _currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear(); Items.Clear();
OverdueItems.Clear(); Rows.Clear();
OpenItems.Clear();
CompletedItems.Clear();
HasOverdue = false; HasOverdue = false;
HasOpen = false; HasOpen = false;
HasCompleted = false; HasCompleted = false;
@@ -459,7 +476,54 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.IsExpanded = saved; 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. // Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level. // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet(); 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; if (!emitted.Add(parent.Id)) continue;
flat.Add(parent); flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification // 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) if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{ {
var children = Items.Where(r => r.ParentTaskId == parent.Id); var children = Items.Where(r => r.ParentTaskId == parent.Id);
@@ -506,18 +570,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
open.Add(r); open.Add(r);
} }
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than return (overdue, open, completed);
// 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);
} }
private void UpdateSubtitle() 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. // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
MoveWithinCollection(Items, source, target, placeBelow); 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 — // Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
// that's determined by Done flag and ScheduledFor date, not drag-drop. // that's determined by Done flag and ScheduledFor date, not drag-drop.
var sourceSection = SectionFor(source); var sourceSection = SectionFor(source);
var targetSection = SectionFor(target); var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection)) 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 listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList(); var orderedIds = Items.Select(i => i.Id).ToList();
@@ -639,10 +692,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private static void MoveWithinCollection( private static void MoveWithinCollection<T>(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll, System.Collections.ObjectModel.ObservableCollection<T> coll,
TaskRowViewModel source, T source,
TaskRowViewModel target, T target,
bool placeBelow) bool placeBelow)
{ {
var srcIdx = coll.IndexOf(source); 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, // 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). // so unchanged rows keep their containers (no Reset-driven full re-render).
private static void SyncCollection( private static void SyncCollection<T>(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst, System.Collections.ObjectModel.ObservableCollection<T> dst,
List<TaskRowViewModel> target) List<T> target)
{ {
var keep = new HashSet<TaskRowViewModel>(target); var keep = new HashSet<T>(target);
for (int i = dst.Count - 1; i >= 0; i--) for (int i = dst.Count - 1; i >= 0; i--)
if (!keep.Contains(dst[i])) if (!keep.Contains(dst[i]))
dst.RemoveAt(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; var idx = Rows.IndexOf(row);
if (OpenItems.Contains(row)) return OpenItems; if (idx < 0) return null;
if (CompletedItems.Contains(row)) return CompletedItems; for (int i = idx - 1; i >= 0; i--)
return null; if (Rows[i] is HeaderRow header) return header;
return UnlabeledSection;
} }
/// <summary> /// <summary>
@@ -778,11 +840,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand] [RelayCommand]
private async Task ClearCompletedAsync() 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 // Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children. // 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) if (ConfirmAsync is not null)
{ {
@@ -1058,6 +1121,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand] [RelayCommand]
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted; 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; public event EventHandler? OpenListSettingsRequested;
[RelayCommand] [RelayCommand]
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.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" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView" x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
x:DataType="vm:TasksIslandViewModel"> x:DataType="vm:TasksIslandViewModel">
@@ -89,82 +89,64 @@
Command="{Binding OpenNotesCommand}" Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/> Content="{loc:Tr tasks.notesPinnedRow}"/>
<!-- Task list --> <!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group
<ScrollViewer> headers are regular entries, not a separate ItemsControl per section, so a
<StackPanel Margin="10,4"> VirtualizingStackPanel actually bounds the realized container count. -->
<ListBox x:Name="RowsListBox"
<!-- OVERDUE --> ItemsSource="{Binding Rows}"
<StackPanel IsVisible="{Binding HasOverdue}"> Background="Transparent"
<TextBlock Classes="eyebrow section-label overdue" BorderThickness="0"
Text="{loc:Tr tasks.overdue}" Margin="14,14,14,6"/> Padding="10,4">
<ItemsControl ItemsSource="{Binding OverdueItems}"> <ListBox.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsPanelTemplate>
<DataTemplate DataType="vm:TaskRowViewModel"> <VirtualizingStackPanel/>
<Button Classes="flat" HorizontalAlignment="Stretch" </ItemsPanelTemplate>
HorizontalContentAlignment="Stretch" </ListBox.ItemsPanel>
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" <ListBox.Styles>
CommandParameter="{Binding}"> <!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's
<islands:TaskRowView/> SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has
</Button> no "no selection" mode, so neutralize the app-wide selected/pointerover
</DataTemplate> ContentPresenter overlay (App.axaml) instead of letting it double up with
</ItemsControl.ItemTemplate> TaskRowView's own hover/selected style. -->
</ItemsControl> <Style Selector="ListBoxItem">
</StackPanel> <Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<!-- TASKS --> <Setter Property="CornerRadius" Value="0"/>
<StackPanel IsVisible="{Binding HasOpen}"> <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<TextBlock Classes="eyebrow section-label" <Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/>
Text="{loc:Tr tasks.tasks}" Margin="14,14,14,6" </Style>
IsVisible="{Binding ShowOpenLabel}"/> <Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<ItemsControl ItemsSource="{Binding OpenItems}"> <Setter Property="Background" Value="Transparent"/>
<ItemsControl.ItemTemplate> </Style>
<DataTemplate DataType="vm:TaskRowViewModel"> <Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Button Classes="flat" HorizontalAlignment="Stretch" <Setter Property="Background" Value="Transparent"/>
HorizontalContentAlignment="Stretch" </Style>
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" </ListBox.Styles>
CommandParameter="{Binding}"> <ListBox.DataTemplates>
<islands:TaskRowView/> <DataTemplate DataType="vm:HeaderRow">
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- COMPLETED -->
<StackPanel>
<StackPanel.IsVisible>
<MultiBinding Converter="{x:Static converters:BoolConverters.And}">
<Binding Path="HasCompleted"/>
<Binding Path="IsShowingCompleted"/>
</MultiBinding>
</StackPanel.IsVisible>
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6"> <Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
<TextBlock Grid.Column="0" Classes="eyebrow section-label" <TextBlock Grid.Column="0" Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" VerticalAlignment="Center"/> Classes.overdue="{Binding IsOverdue}"
<Button Grid.Column="1" Classes="icon-btn" Text="{Binding Label}" VerticalAlignment="Center"/>
Command="{Binding ClearCompletedCommand}" <Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}"
Command="{Binding ActionCommand}"
ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}" ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}"
VerticalAlignment="Center"> VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13" <PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/> Foreground="{DynamicResource BloodBrush}"/>
</Button> </Button>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding CompletedItems}"> </DataTemplate>
<ItemsControl.ItemTemplate> <DataTemplate DataType="vm:TaskRowViewModel">
<DataTemplate DataType="vm:TaskRowViewModel"> <Button Classes="flat" HorizontalAlignment="Stretch"
<Button Classes="flat" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
HorizontalContentAlignment="Stretch" Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" CommandParameter="{Binding}">
CommandParameter="{Binding}"> <islands:TaskRowView/>
<islands:TaskRowView/> </Button>
</Button> </DataTemplate>
</DataTemplate> </ListBox.DataTemplates>
</ItemsControl.ItemTemplate> </ListBox>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel> </DockPanel>
</UserControl> </UserControl>
@@ -1,5 +1,4 @@
using System; using System;
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; 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 // Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in
// built-in selection scrolling, so a programmatic select (e.g. Mission Control's // app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is
// "Open in app") would otherwise highlight a row that stays off-screen. // 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() private void ScrollSelectedIntoView()
{ {
if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return; if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return;
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
{
var match = this.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => ReferenceEquals(b.DataContext, target));
match?.BringIntoView();
}, DispatcherPriority.Background);
} }
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message) private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
@@ -363,13 +357,12 @@ public partial class TasksIslandView : UserControl
return null; return null;
} }
// The next row in Rows is only "in the same section" while it's a TaskRowViewModel —
// hitting a HeaderRow (or the end of the list) means row was the last one in its section.
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row) private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{ {
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems }) var idx = vm.Rows.IndexOf(row);
{ if (idx < 0 || idx + 1 >= vm.Rows.Count) return null;
var idx = section.IndexOf(row); return vm.Rows[idx + 1] as TaskRowViewModel;
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
}
return null;
} }
} }
@@ -1,3 +1,4 @@
using System.Collections.Specialized;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
@@ -84,6 +85,49 @@ public class TasksIslandRegroupTests : IDisposable
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
// Rows is the flat HeaderRow|TaskRowViewModel union — a row's section is read off the
// nearest preceding HeaderRow, mirroring TasksIslandViewModel.SectionFor. HasAction is
// unique to the Completed header (its Clear-completed button), so it disambiguates
// Completed from the (also non-overdue) Open header without depending on locale text.
private enum Section { None, Overdue, Open, Completed }
private static Section SectionOf(TasksIslandViewModel vm, TaskRowViewModel row)
{
var idx = vm.Rows.IndexOf(row);
if (idx < 0) return Section.None;
for (int i = idx - 1; i >= 0; i--)
if (vm.Rows[i] is HeaderRow h)
return h.IsOverdue ? Section.Overdue : h.HasAction ? Section.Completed : Section.Open;
return Section.Open;
}
private static bool IsInCompleted(TasksIslandViewModel vm, string taskId) =>
vm.Rows.OfType<TaskRowViewModel>().Any(r => r.Id == taskId && SectionOf(vm, r) == Section.Completed);
private static bool IsInOpen(TasksIslandViewModel vm, string taskId, bool requireChild = false) =>
vm.Rows.OfType<TaskRowViewModel>().Any(r =>
r.Id == taskId && (!requireChild || r.IsChild) && SectionOf(vm, r) == Section.Open);
private async Task SeedTasksAsync(params (string Id, TaskStatus Status, DateTime? ScheduledFor, int SortOrder)[] tasks)
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
foreach (var t in tasks)
{
db.Tasks.Add(new TaskEntity
{
Id = t.Id,
ListId = "list1",
Title = t.Id,
CreatedAt = DateTime.UtcNow,
Status = t.Status,
ScheduledFor = t.ScheduledFor,
SortOrder = t.SortOrder,
});
}
await db.SaveChangesAsync();
}
private static ListNavItemViewModel VirtualList(string id, string name) => private static ListNavItemViewModel VirtualList(string id, string name) =>
new() { Id = id, Kind = ListKind.Virtual, Name = name }; new() { Id = id, Kind = ListKind.Virtual, Name = name };
@@ -171,10 +215,10 @@ public class TasksIslandRegroupTests : IDisposable
// Parents with children collapse by default; expand to surface the nested child. // Parents with children collapse by default; expand to surface the nested child.
vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1")); vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
// Child with Done status under an open Planning parent should NOT go to CompletedItems // Child with Done status under an open Planning parent should NOT land in the Completed section
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1"); Assert.False(IsInCompleted(vm, "c1"));
// Child should appear nested (IsChild == true) in OpenItems // Child should appear nested (IsChild == true) in the Open section
Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild); Assert.True(IsInOpen(vm, "c1", requireChild: true));
} }
[Fact] [Fact]
@@ -189,7 +233,120 @@ public class TasksIslandRegroupTests : IDisposable
var vm = BuildViewModel(); var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default")); await LoadAndWaitAsync(vm, UserList("list1", "Default"));
Assert.Contains(vm.CompletedItems, r => r.Id == "p1"); Assert.True(IsInCompleted(vm, "p1"));
Assert.Contains(vm.CompletedItems, r => r.Id == "c1"); Assert.True(IsInCompleted(vm, "c1"));
}
[Fact]
public async Task Regroup_Rows_HeaderPositionsAndCountsMatchSections()
{
var yesterday = DateTime.Today.AddDays(-1);
await SeedTasksAsync(
("overdue1", TaskStatus.Idle, yesterday, 0),
("open1", TaskStatus.Idle, null, 1),
("completed1", TaskStatus.Done, null, 2));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
Assert.Equal(6, vm.Rows.Count);
var overdueHeader = Assert.IsType<HeaderRow>(vm.Rows[0]);
Assert.True(overdueHeader.IsOverdue);
Assert.Equal(1, overdueHeader.Count);
Assert.Equal("overdue1", Assert.IsType<TaskRowViewModel>(vm.Rows[1]).Id);
// ShowOpenLabel is true here because Overdue is also present — the Open header IS emitted.
var openHeader = Assert.IsType<HeaderRow>(vm.Rows[2]);
Assert.False(openHeader.IsOverdue);
Assert.False(openHeader.HasAction);
Assert.Equal(1, openHeader.Count);
Assert.Equal("open1", Assert.IsType<TaskRowViewModel>(vm.Rows[3]).Id);
var completedHeader = Assert.IsType<HeaderRow>(vm.Rows[4]);
Assert.True(completedHeader.HasAction);
Assert.Equal(1, completedHeader.Count);
Assert.Equal("completed1", Assert.IsType<TaskRowViewModel>(vm.Rows[5]).Id);
}
[Fact]
public async Task Regroup_EmptySections_EmitNoHeader()
{
// Open-only: no Overdue means ShowOpenLabel is false too, so the Open header is never
// emitted either — Rows should hold exactly the one task row, nothing else.
await SeedTasksAsync(("open1", TaskStatus.Idle, null, 0));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
Assert.False(vm.HasOverdue);
Assert.False(vm.HasCompleted);
Assert.False(vm.ShowOpenLabel);
var row = Assert.Single(vm.Rows);
Assert.Equal("open1", Assert.IsType<TaskRowViewModel>(row).Id);
}
[Fact]
public async Task Regroup_CompletedSection_HiddenWhileIsShowingCompletedFalse()
{
await SeedTasksAsync(
("open1", TaskStatus.Idle, null, 0),
("completed1", TaskStatus.Done, null, 1));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
Assert.True(IsInCompleted(vm, "completed1"));
vm.IsShowingCompleted = false;
Assert.DoesNotContain(vm.Rows.OfType<HeaderRow>(), h => h.HasAction);
Assert.DoesNotContain(vm.Rows.OfType<TaskRowViewModel>(), r => r.Id == "completed1");
// HasCompleted reflects the underlying data, independent of the toggle.
Assert.True(vm.HasCompleted);
vm.IsShowingCompleted = true;
Assert.True(IsInCompleted(vm, "completed1"));
}
[Fact]
public async Task Rows_ParentExpandCollapse_ReconcilesGranularly_NoResetEvent()
{
await SeedPlanningWithChildAsync(
parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active,
childStatus: TaskStatus.Idle,
parentId: "p1",
childId: "c1");
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var actions = new List<NotifyCollectionChangedAction>();
vm.Rows.CollectionChanged += (_, e) => actions.Add(e.Action);
vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
Assert.NotEmpty(actions);
Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, actions);
}
[Fact]
public async Task ReorderAsync_WithinOpenSection_MovesRowInItemsAndRows()
{
await SeedTasksAsync(
("t1", TaskStatus.Idle, null, 0),
("t2", TaskStatus.Idle, null, 1),
("t3", TaskStatus.Idle, null, 2));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var t1 = vm.Items.First(r => r.Id == "t1");
var t3 = vm.Items.First(r => r.Id == "t3");
await vm.ReorderAsync(t1, t3, placeBelow: true);
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Items.Select(r => r.Id));
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Rows.OfType<TaskRowViewModel>().Select(r => r.Id));
} }
} }
@@ -211,6 +211,19 @@ public class TasksIslandViewModelPlanningTests
PlanningPhase phase = PlanningPhase.None) PlanningPhase phase = PlanningPhase.None)
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase }; => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
// vm.Rows is the flat HeaderRow|TaskRowViewModel union — a row is "in the Open section" when
// it's present in Rows and the nearest preceding HeaderRow (if any) isn't Overdue/Completed.
// HasAction uniquely identifies the Completed header (its Clear-completed button).
private static bool IsVisibleInOpen(TasksIslandViewModel vm, TaskRowViewModel row)
{
var idx = vm.Rows.IndexOf(row);
if (idx < 0) return false;
for (int i = idx - 1; i >= 0; i--)
if (vm.Rows[i] is HeaderRow h)
return !h.IsOverdue && !h.HasAction;
return true;
}
[Fact] [Fact]
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands() public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
{ {
@@ -221,16 +234,16 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child1, child2]); var (vm, _) = VmFactory.Create([parent, child1, child2]);
// Collapsed by default — children hidden, parent still present // Collapsed by default — children hidden, parent still present
Assert.DoesNotContain(child1, vm.OpenItems); Assert.False(IsVisibleInOpen(vm, child1));
Assert.DoesNotContain(child2, vm.OpenItems); Assert.False(IsVisibleInOpen(vm, child2));
Assert.Contains(parent, vm.OpenItems); Assert.True(IsVisibleInOpen(vm, parent));
// Expand the parent // Expand the parent
vm.ToggleExpandCommand.Execute(parent); vm.ToggleExpandCommand.Execute(parent);
// Children now visible // Children now visible
Assert.Contains(child1, vm.OpenItems); Assert.True(IsVisibleInOpen(vm, child1));
Assert.Contains(child2, vm.OpenItems); Assert.True(IsVisibleInOpen(vm, child2));
} }
[Fact] [Fact]
@@ -268,15 +281,15 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child]); var (vm, _) = VmFactory.Create([parent, child]);
// Collapsed by default // Collapsed by default
Assert.DoesNotContain(child, vm.OpenItems); Assert.False(IsVisibleInOpen(vm, child));
// Expand // Expand
vm.ToggleExpandCommand.Execute(parent); vm.ToggleExpandCommand.Execute(parent);
Assert.Contains(child, vm.OpenItems); Assert.True(IsVisibleInOpen(vm, child));
// Collapse again // Collapse again
vm.ToggleExpandCommand.Execute(parent); vm.ToggleExpandCommand.Execute(parent);
Assert.DoesNotContain(child, vm.OpenItems); Assert.False(IsVisibleInOpen(vm, child));
} }
[Fact] [Fact]
@@ -291,7 +304,7 @@ public class TasksIslandViewModelPlanningTests
Assert.False(orphan.ParentInView); Assert.False(orphan.ParentInView);
Assert.False(orphan.ShowAsChild); Assert.False(orphan.ShowAsChild);
Assert.False(orphan.IsDraft); Assert.False(orphan.IsDraft);
Assert.Contains(orphan, vm.OpenItems); Assert.True(IsVisibleInOpen(vm, orphan));
} }
[Fact] [Fact]