diff --git a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs index 50c479c4..43c23dfe 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs @@ -1,13 +1,12 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Filtering.Filters; -public sealed class ReviewFilter : ITaskListFilter +public sealed class ReviewFilter : TaskListFilterBase { - public string Id => "virtual:review"; - public bool Matches(TaskEntity t) => - t.Status == TaskStatus.WaitingForReview; - public bool ShouldCount(TaskEntity t) => Matches(t); - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + public override string Id => "virtual:review"; + protected override Expression> MatchExpr => t => t.Status == TaskStatus.WaitingForReview; + public override bool ShouldCount(TaskEntity t) => Matches(t); } diff --git a/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs index 636fbd4e..a054a43b 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Filter for a smart list keyed off a boolean/nullable task flag /// (My Day, Important, Planned). Counts only non-done matches. /// -public sealed class SmartFlagFilter(string id, Func flag) : ITaskListFilter +public sealed class SmartFlagFilter(string id, Expression> flag) : TaskListFilterBase { - public string Id => id; - public bool Matches(TaskEntity t) => flag(t); - public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + private readonly Func _flag = flag.Compile(); + + public override string Id => id; + protected override Expression> MatchExpr => flag; + public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done; } diff --git a/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs index 93aa2746..abafd8f4 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Virtual list filter matching tasks by a single status (Queued, Running). /// Planning parents appear contextually when they host a matching child. /// -public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter +public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase { - public string Id => id; - public bool Matches(TaskEntity t) => t.Status == status; - public bool ShouldCount(TaskEntity t) => t.Status == status; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => + public override string Id => id; + protected override Expression> MatchExpr => t => t.Status == status; + public override bool ShouldCount(TaskEntity t) => t.Status == status; + public override bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => PlanningRules.IsPlanningParent(t) && PlanningRules.HasMatchingChild(t, all, c => c.Status == status); } diff --git a/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs b/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs new file mode 100644 index 00000000..be0db003 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/TaskListFilterBase.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Filtering.Filters; + +/// +/// Base for implementations: subclasses express their +/// primary-match condition once as an expression tree (), which +/// doubles as a SQL-translatable predicate () and, compiled +/// on first use, as the in-memory predicate. +/// +public abstract class TaskListFilterBase : ITaskListFilter +{ + private Func? _compiled; + + public abstract string Id { get; } + + protected abstract Expression> MatchExpr { get; } + + public Expression> MatchExpression => MatchExpr; + + public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t); + + public abstract bool ShouldCount(TaskEntity t); + + public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs index 3373dbf8..d340dcea 100644 --- a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters; /// Filter for any user-defined list. Constructed on demand from the list id — /// one instance per list. /// -public sealed class UserListFilter : ITaskListFilter +public sealed class UserListFilter : TaskListFilterBase { private readonly string _listId; @@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter Id = $"user:{listId}"; } - public string Id { get; } - public bool Matches(TaskEntity t) => t.ListId == _listId; - public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done; - public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; + public override string Id { get; } + protected override Expression> MatchExpr => t => t.ListId == _listId; + public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done; } diff --git a/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs b/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs index 79b13435..0782df3d 100644 --- a/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs +++ b/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using ClaudeDo.Data.Models; namespace ClaudeDo.Data.Filtering; @@ -15,6 +16,9 @@ public interface ITaskListFilter /// True if is a primary citizen of this list — appears as a row. bool Matches(TaskEntity t); + /// The primary-match predicate as an expression tree, so EF Core can push it into SQL. + Expression> MatchExpression { get; } + /// True if should be counted in this list's badge. bool ShouldCount(TaskEntity t); diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index c6870765..9b7d5994 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -484,12 +484,21 @@ - + + + + + diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 3e3f0e66..c0649ebe 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -45,6 +45,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase // Set by the custom drag while this row is being dragged — drives the "grabbed" row style. [ObservableProperty] private bool _isDragging; + // True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to + // suppress the ordinary hover highlight/transitions so they don't fight the hint. + public bool IsDropTarget => DropHintAbove || DropHintBelow; + public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining && !IsManual; @@ -289,6 +293,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); } partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); } + partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget)); + partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget)); public void RefreshLocalized() { diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 6ccd42f9..c83cbc76 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -336,26 +336,53 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable try { await using var db = await _dbFactory.CreateDbContextAsync(ct); - var all = await db.Tasks - .Include(t => t.List) - .Include(t => t.Worktree) - .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) - .ToListAsync(ct); + var filter = _filters.Resolve(list.Id); + var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree); + + var filteredList = filter is null + ? new List() + : await baseQuery.Where(filter.MatchExpression).ToListAsync(ct); ct.ThrowIfCancellationRequested(); - var filter = _filters.Resolve(list.Id); - var filteredList = filter is null - ? new List() - : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList(); - var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); - var existingIds = filteredList.Select(t => t.Id).ToHashSet(); - foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!))) + if (filter is not null) { - if (existingIds.Add(c.Id)) - filteredList.Add(c); + // Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running + // child): only fetch candidates that could plausibly qualify — parents of tasks we already + // matched — instead of scanning the whole table. + var candidateParentIds = filteredList + .Where(t => t.ParentTaskId != null) + .Select(t => t.ParentTaskId!) + .Distinct() + .ToList(); + if (candidateParentIds.Count > 0) + { + var existingParentIds = filteredList.Select(t => t.Id).ToHashSet(); + var parentCandidates = await baseQuery + .Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id)) + .ToListAsync(ct); + foreach (var p in parentCandidates) + if (filter.MatchesAsContext(p, filteredList)) + filteredList.Add(p); + } } + ct.ThrowIfCancellationRequested(); + + // Pull in every child of an already-matched top-level row, regardless of whether the child + // itself matches the filter, so subtasks render nested under their parent. + var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); + if (topIds.Count > 0) + { + var existingIds = filteredList.Select(t => t.Id).ToHashSet(); + var extraChildren = await baseQuery + .Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id)) + .ToListAsync(ct); + filteredList.AddRange(extraChildren); + } + + filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList(); + var showListChip = list.Kind == ListKind.Virtual; foreach (var t in filteredList) { diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 24cb5ae8..cf4bc51e 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -7,15 +7,16 @@ x:DataType="vm:TaskRowViewModel"> - + - - + + + + @@ -31,58 +32,9 @@ Margin="0" Classes.selected="{Binding IsSelected}" Classes.dragging="{Binding IsDragging}" - Classes.done="{Binding Done}"> - - - - - - - - - - - - - - - - - - - - - - - - - - + Classes.drop-target="{Binding IsDropTarget}" + Classes.done="{Binding Done}" + ContextRequested="OnRowContextRequested"> @@ -252,9 +204,9 @@ - - + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index 8fc3e09a..e7f2524c 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -1,9 +1,11 @@ using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.VisualTree; using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.ViewModels.Islands; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -19,6 +21,57 @@ public partial class TaskRowView : UserControl this.GetVisualAncestors().OfType() .Select(ic => ic.DataContext).OfType().FirstOrDefault(); + // The context menu is built here instead of declared in XAML so the ~18 MenuItems only + // exist while a row's menu is actually open, not for every row rendered in the list. + private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (sender is not Border border) return; + if (DataContext is not TaskRowViewModel row) return; + if (FindTasksVm() is not { } vm) return; + + // OnTunnelPointerPressed (TasksIslandView) only selects on the left button, so + // right-click needs its own explicit selection before the menu opens. + vm.SelectedTask = row; + + var menu = new ContextMenu { DataContext = row }; + + MenuItem MakeItem(string key, EventHandler click, bool isVisible = true, string? tag = null) + { + var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible }; + if (tag is not null) item.Tag = tag; + item.Click += click; + return item; + } + + menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue)); + menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue)); + menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning)); + menu.Items.Add(new Separator()); + + var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row }; + markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done")); + markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled")); + markAs.Items.Add(new Separator()); + markAs.Items.Add(MakeItem("tasks.ctxMarkManual", OnToggleManualClick, !row.IsManual)); + markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual)); + menu.Items.Add(markAs); + + menu.Items.Add(new Separator()); + menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick)); + menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession)); + menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning)); + menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning)); + menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning)); + menu.Items.Add(new Separator()); + menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick)); + menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule)); + menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay)); + menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay)); + + menu.Open(border); + e.Handled = true; + } + private async void OnSendToQueueClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) diff --git a/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs new file mode 100644 index 00000000..7120c10b --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/MatchExpressionSqlTests.cs @@ -0,0 +1,57 @@ +using ClaudeDo.Data.Filtering.Filters; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +/// +/// Proves that is a real +/// expression tree that EF Core can translate into SQL, not just an in-memory delegate — the +/// whole point of splitting it out from Matches. +/// +public sealed class MatchExpressionSqlTests : IDisposable +{ + private readonly string _dbPath; + private readonly DbContextOptions _options; + + public MatchExpressionSqlTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_matchexpr_{Guid.NewGuid():N}.db"); + _options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + + using var ctx = new ClaudeDoDbContext(_options); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + [Fact] + public void StatusFilter_MatchExpression_translates_to_a_SQL_WHERE_on_status() + { + var filter = new StatusFilter("virtual:queued", TaskStatus.Queued); + + using var ctx = new ClaudeDoDbContext(_options); + var sql = ctx.Tasks.Where(filter.MatchExpression).ToQueryString(); + + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("status", sql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UserListFilter_MatchExpression_translates_to_a_SQL_WHERE_on_list_id() + { + var filter = new UserListFilter("abc"); + + using var ctx = new ClaudeDoDbContext(_options); + var sql = ctx.Tasks.Where(filter.MatchExpression).ToQueryString(); + + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("list_id", sql, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs index 9044eb88..a8a2166d 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; using Xunit; @@ -20,4 +21,25 @@ public class TaskRowViewModelTests vm.Status = s; Assert.Equal(expected, vm.StatusChipClass); } + + [Fact] + public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + Assert.False(vm.IsDropTarget); + + vm.DropHintAbove = true; + Assert.True(vm.IsDropTarget); + Assert.Contains(nameof(TaskRowViewModel.IsDropTarget), raised); + + vm.DropHintAbove = false; + vm.DropHintBelow = true; + Assert.True(vm.IsDropTarget); + + vm.DropHintBelow = false; + Assert.False(vm.IsDropTarget); + } }