Merge claudedo/1891227787ae4085a52c112bd1dcd75a
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering.Filters;
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
public sealed class ReviewFilter : ITaskListFilter
|
public sealed class ReviewFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => "virtual:review";
|
public override string Id => "virtual:review";
|
||||||
public bool Matches(TaskEntity t) =>
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
|
||||||
t.Status == TaskStatus.WaitingForReview;
|
public override bool ShouldCount(TaskEntity t) => Matches(t);
|
||||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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
|
/// Filter for a smart list keyed off a boolean/nullable task flag
|
||||||
/// (My Day, Important, Planned). Counts only non-done matches.
|
/// (My Day, Important, Planned). Counts only non-done matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
|
public sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
|
||||||
public bool Matches(TaskEntity t) => flag(t);
|
|
||||||
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
|
public override string Id => id;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
|
||||||
|
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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).
|
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
||||||
/// Planning parents appear contextually when they host a matching child.
|
/// Planning parents appear contextually when they host a matching child.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
|
public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
public override string Id => id;
|
||||||
public bool Matches(TaskEntity t) => t.Status == status;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
|
||||||
public bool ShouldCount(TaskEntity t) => t.Status == status;
|
public override bool ShouldCount(TaskEntity t) => t.Status == status;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||||
PlanningRules.IsPlanningParent(t) &&
|
PlanningRules.IsPlanningParent(t) &&
|
||||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
|
||||||
|
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
|
||||||
|
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
|
||||||
|
/// on first use, as the in-memory <see cref="Matches"/> predicate.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TaskListFilterBase : ITaskListFilter
|
||||||
|
{
|
||||||
|
private Func<TaskEntity, bool>? _compiled;
|
||||||
|
|
||||||
|
public abstract string Id { get; }
|
||||||
|
|
||||||
|
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
|
||||||
|
|
||||||
|
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
|
||||||
|
|
||||||
|
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
|
||||||
|
|
||||||
|
public abstract bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
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 —
|
/// Filter for any user-defined list. Constructed on demand from the list id —
|
||||||
/// one instance per list.
|
/// one instance per list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UserListFilter : ITaskListFilter
|
public sealed class UserListFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
private readonly string _listId;
|
private readonly string _listId;
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
|
|||||||
Id = $"user:{listId}";
|
Id = $"user:{listId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id { get; }
|
public override string Id { get; }
|
||||||
public bool Matches(TaskEntity t) => t.ListId == _listId;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
|
||||||
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering;
|
namespace ClaudeDo.Data.Filtering;
|
||||||
@@ -15,6 +16,9 @@ public interface ITaskListFilter
|
|||||||
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
||||||
bool Matches(TaskEntity t);
|
bool Matches(TaskEntity t);
|
||||||
|
|
||||||
|
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
|
||||||
|
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
|
||||||
|
|
||||||
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
||||||
bool ShouldCount(TaskEntity t);
|
bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
|||||||
@@ -484,12 +484,21 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
|
<!-- "Grabbed" row: collapses out of layout entirely while the custom drag runs — the
|
||||||
|
floating ghost snapshot (TaskDragController) is what the user sees moving. -->
|
||||||
<Style Selector="Border.task-row.dragging">
|
<Style Selector="Border.task-row.dragging">
|
||||||
<Setter Property="Opacity" Value="0.55" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
<Setter Property="RenderTransform" Value="scale(1.03)" />
|
<Setter Property="Transitions" Value="{x:Null}" />
|
||||||
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
|
</Style>
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
|
||||||
|
<!-- Row currently hovered while dragging (shows the dashed drop-hint gap): suppress the
|
||||||
|
ordinary hover highlight and transitions so they don't fight the hint. Placed after
|
||||||
|
Border.task-row:pointerover so its extra class also wins on declaration order. -->
|
||||||
|
<Style Selector="Border.task-row.drop-target">
|
||||||
|
<Setter Property="Transitions" Value="{x:Null}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row.drop-target:pointerover">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||||
|
|||||||
@@ -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.
|
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||||
[ObservableProperty] private bool _isDragging;
|
[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
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
||||||
&& !IsRefining && !IsManual;
|
&& !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 OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||||||
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
|
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()
|
public void RefreshLocalized()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -336,26 +336,53 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var all = await db.Tasks
|
var filter = _filters.Resolve(list.Id);
|
||||||
.Include(t => t.List)
|
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
|
||||||
.Include(t => t.Worktree)
|
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
var filteredList = filter is null
|
||||||
.ToListAsync(ct);
|
? new List<TaskEntity>()
|
||||||
|
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var filter = _filters.Resolve(list.Id);
|
if (filter is not null)
|
||||||
var filteredList = filter is null
|
|
||||||
? new List<TaskEntity>()
|
|
||||||
: 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 (existingIds.Add(c.Id))
|
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
|
||||||
filteredList.Add(c);
|
// 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;
|
var showListChip = list.Kind == ListKind.Virtual;
|
||||||
foreach (var t in filteredList)
|
foreach (var t in filteredList)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,15 +7,16 @@
|
|||||||
x:DataType="vm:TaskRowViewModel">
|
x:DataType="vm:TaskRowViewModel">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="6"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Above-row indicator: lives in the 6px gap between cards -->
|
<!-- Above-row indicator: dashed placeholder gap showing where the dragged row will land -->
|
||||||
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
|
<Grid Grid.Row="0" Height="52" IsVisible="{Binding DropHintAbove}">
|
||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
|
||||||
IsVisible="{Binding DropHintAbove}"/>
|
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
@@ -31,58 +32,9 @@
|
|||||||
Margin="0"
|
Margin="0"
|
||||||
Classes.selected="{Binding IsSelected}"
|
Classes.selected="{Binding IsSelected}"
|
||||||
Classes.dragging="{Binding IsDragging}"
|
Classes.dragging="{Binding IsDragging}"
|
||||||
Classes.done="{Binding Done}">
|
Classes.drop-target="{Binding IsDropTarget}"
|
||||||
<Border.ContextMenu>
|
Classes.done="{Binding Done}"
|
||||||
<ContextMenu>
|
ContextRequested="OnRowContextRequested">
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxSendToQueue}"
|
|
||||||
IsVisible="{Binding CanSendToQueue}"
|
|
||||||
Click="OnSendToQueueClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromQueue}"
|
|
||||||
IsVisible="{Binding CanRemoveFromQueue}"
|
|
||||||
Click="OnRemoveFromQueueClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxCancelExecution}"
|
|
||||||
IsVisible="{Binding IsRunning}"
|
|
||||||
Click="OnCancelExecutionClick"/>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
|
|
||||||
IsVisible="{Binding !IsManual}"
|
|
||||||
Click="OnToggleManualClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
|
|
||||||
IsVisible="{Binding IsManual}"
|
|
||||||
Click="OnToggleManualClick"/>
|
|
||||||
</MenuItem>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
|
|
||||||
Click="OnOpenConPtySessionClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
|
|
||||||
Click="OnOpenPlanningSessionClick"
|
|
||||||
IsVisible="{Binding CanOpenPlanningSession}"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
|
||||||
Click="OnResumePlanningSessionClick"
|
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
|
|
||||||
Click="OnFinalizePlanningSessionClick"
|
|
||||||
IsVisible="{Binding CanFinalizePlanning}"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
|
||||||
Click="OnDiscardPlanningSessionClick"
|
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
|
||||||
IsVisible="{Binding HasSchedule}"
|
|
||||||
Click="OnClearScheduleClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
|
|
||||||
IsVisible="{Binding CanAddToMyDay}"
|
|
||||||
Click="OnAddToMyDayClick"/>
|
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
|
|
||||||
IsVisible="{Binding IsMyDay}"
|
|
||||||
Click="OnRemoveFromMyDayClick"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Border.ContextMenu>
|
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
<!-- Chevron toggle (only for planning parent tasks) -->
|
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||||
@@ -252,9 +204,9 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
||||||
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
<Grid Grid.Row="2" Height="52" IsVisible="{Binding DropHintBelow}">
|
||||||
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
|
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
|
||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
|
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
|
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -19,6 +21,57 @@ public partial class TaskRowView : UserControl
|
|||||||
this.GetVisualAncestors().OfType<ItemsControl>()
|
this.GetVisualAncestors().OfType<ItemsControl>()
|
||||||
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
|
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().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<RoutedEventArgs> 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)
|
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves that <see cref="ClaudeDo.Data.Filtering.ITaskListFilter.MatchExpression"/> 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 <c>Matches</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MatchExpressionSqlTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly DbContextOptions<ClaudeDoDbContext> _options;
|
||||||
|
|
||||||
|
public MatchExpressionSqlTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_matchexpr_{Guid.NewGuid():N}.db");
|
||||||
|
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -20,4 +21,25 @@ public class TaskRowViewModelTests
|
|||||||
vm.Status = s;
|
vm.Status = s;
|
||||||
Assert.Equal(expected, vm.StatusChipClass);
|
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<string?>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user