improve Frontend
This commit is contained in:
@@ -51,6 +51,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
|
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
|
||||||
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||||
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
@@ -74,5 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
|
|
||||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||||
|
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskSortOrder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "sort_order",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
// Backfill existing rows with a per-list dense order (0..N-1) by creation time
|
||||||
|
// so today's UI order is preserved after the migration.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (PARTITION BY list_id ORDER BY created_at) - 1) AS rn
|
||||||
|
FROM tasks
|
||||||
|
)
|
||||||
|
UPDATE tasks SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = tasks.id);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_list_sort",
|
||||||
|
table: "tasks",
|
||||||
|
columns: new[] { "list_id", "sort_order" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_list_sort",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sort_order",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("scheduled_for");
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
b.Property<DateTime?>("StartedAt")
|
b.Property<DateTime?>("StartedAt")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("started_at");
|
.HasColumnName("started_at");
|
||||||
@@ -307,6 +313,9 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasDatabaseName("idx_tasks_status");
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
|
b.HasIndex("ListId", "SortOrder")
|
||||||
|
.HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
|
||||||
b.ToTable("tasks", (string)null);
|
b.ToTable("tasks", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public sealed class TaskEntity
|
|||||||
public bool IsStarred { get; set; }
|
public bool IsStarred { get; set; }
|
||||||
public bool IsMyDay { get; set; }
|
public bool IsMyDay { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Append at bottom of the list by default: SortOrder = max(listId) + 1.
|
||||||
|
var maxSort = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == entity.ListId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
entity.SortOrder = (maxSort ?? -1) + 1;
|
||||||
|
|
||||||
_context.Tasks.Add(entity);
|
_context.Tasks.Add(entity);
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -38,10 +45,32 @@ public sealed class TaskRepository
|
|||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.ListId == listId)
|
.Where(t => t.ListId == listId)
|
||||||
.OrderBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
|
||||||
|
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ReorderAsync(string listId, IReadOnlyList<string> orderedTaskIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (orderedTaskIds.Count == 0) return;
|
||||||
|
|
||||||
|
var idSet = orderedTaskIds.ToHashSet();
|
||||||
|
var tasks = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
for (int i = 0; i < orderedTaskIds.Count; i++)
|
||||||
|
{
|
||||||
|
var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]);
|
||||||
|
if (task is not null) task.SortOrder = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
// Kept for backwards-compatibility with callers using the old name.
|
// Kept for backwards-compatibility with callers using the old name.
|
||||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||||
=> GetByListIdAsync(listId, ct);
|
=> GetByListIdAsync(listId, ct);
|
||||||
@@ -205,7 +234,7 @@ public sealed class TaskRepository
|
|||||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|||||||
@@ -272,6 +272,18 @@
|
|||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.flat:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.flat:pressed /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.flat:focus /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- TASK ROW -->
|
<!-- TASK ROW -->
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private DateTime? _scheduledFor;
|
[ObservableProperty] private DateTime? _scheduledFor;
|
||||||
[ObservableProperty] private int _diffAdditions;
|
[ObservableProperty] private int _diffAdditions;
|
||||||
[ObservableProperty] private int _diffDeletions;
|
[ObservableProperty] private int _diffDeletions;
|
||||||
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
var all = await db.Tasks
|
var all = await db.Tasks
|
||||||
.Include(t => t.List)
|
.Include(t => t.List)
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -149,23 +150,83 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||||
var listId = _currentList.Id["user:".Length..];
|
var listId = _currentList.Id["user:".Length..];
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var maxSort = await db.Tasks
|
||||||
|
.Where(t => t.ListId == listId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync();
|
||||||
var entity = new TaskEntity
|
var entity = new TaskEntity
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
ListId = listId,
|
ListId = listId,
|
||||||
Title = NewTaskTitle.Trim(),
|
Title = NewTaskTitle.Trim(),
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
};
|
};
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
||||||
db.Tasks.Add(entity);
|
db.Tasks.Add(entity);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var row = TaskRowViewModel.FromEntity(entity);
|
var row = TaskRowViewModel.FromEntity(entity);
|
||||||
Items.Insert(0, row);
|
Items.Add(row);
|
||||||
Regroup();
|
Regroup();
|
||||||
NewTaskTitle = "";
|
NewTaskTitle = "";
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
||||||
|
|
||||||
|
public void ClearDropHints()
|
||||||
|
{
|
||||||
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
r.DropHintAbove = false;
|
||||||
|
r.DropHintBelow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDropHint(TaskRowViewModel target, bool placeBelow)
|
||||||
|
{
|
||||||
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
var isTarget = ReferenceEquals(r, target);
|
||||||
|
r.DropHintAbove = isTarget && !placeBelow;
|
||||||
|
r.DropHintBelow = isTarget && placeBelow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
|
||||||
|
{
|
||||||
|
if (!CanReorder || _currentList is null) return;
|
||||||
|
if (source.IsRunning || target.IsRunning) return;
|
||||||
|
if (ReferenceEquals(source, target)) return;
|
||||||
|
|
||||||
|
var srcIdx = Items.IndexOf(source);
|
||||||
|
var tgtIdx = Items.IndexOf(target);
|
||||||
|
if (srcIdx < 0 || tgtIdx < 0) return;
|
||||||
|
|
||||||
|
Items.RemoveAt(srcIdx);
|
||||||
|
var newTgtIdx = Items.IndexOf(target);
|
||||||
|
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx;
|
||||||
|
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count;
|
||||||
|
Items.Insert(insertIdx, source);
|
||||||
|
|
||||||
|
var listId = _currentList.Id["user:".Length..];
|
||||||
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var idSet = orderedIds.ToHashSet();
|
||||||
|
var entities = await db.Tasks
|
||||||
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
for (int i = 0; i < orderedIds.Count; i++)
|
||||||
|
{
|
||||||
|
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
||||||
|
if (e is not null) e.SortOrder = i;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
Regroup();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,20 @@
|
|||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
|
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
|
||||||
x:DataType="vm:TaskRowViewModel">
|
x:DataType="vm:TaskRowViewModel">
|
||||||
<Border Classes="task-row"
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="6"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Above-row indicator: lives in the 6px gap between cards -->
|
||||||
|
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||||
|
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||||
|
IsVisible="{Binding DropHintAbove}"/>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" Classes="task-row"
|
||||||
|
Margin="0"
|
||||||
Classes.selected="{Binding IsSelected}"
|
Classes.selected="{Binding IsSelected}"
|
||||||
Classes.done="{Binding Done}">
|
Classes.done="{Binding Done}">
|
||||||
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
|
||||||
@@ -110,4 +123,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
||||||
|
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
||||||
|
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||||
|
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -80,7 +80,13 @@
|
|||||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}"
|
||||||
|
PointerPressed="OnRowPointerPressed"
|
||||||
|
PointerMoved="OnRowPointerMoved"
|
||||||
|
PointerReleased="OnRowPointerReleased"
|
||||||
|
DragDrop.AllowDrop="True"
|
||||||
|
DragDrop.DragOver="OnRowDragOver"
|
||||||
|
DragDrop.Drop="OnRowDrop">
|
||||||
<islands:TaskRowView/>
|
<islands:TaskRowView/>
|
||||||
</Button>
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -99,7 +105,13 @@
|
|||||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}"
|
||||||
|
PointerPressed="OnRowPointerPressed"
|
||||||
|
PointerMoved="OnRowPointerMoved"
|
||||||
|
PointerReleased="OnRowPointerReleased"
|
||||||
|
DragDrop.AllowDrop="True"
|
||||||
|
DragDrop.DragOver="OnRowDragOver"
|
||||||
|
DragDrop.Drop="OnRowDrop">
|
||||||
<islands:TaskRowView/>
|
<islands:TaskRowView/>
|
||||||
</Button>
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -123,7 +135,13 @@
|
|||||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}"
|
||||||
|
PointerPressed="OnRowPointerPressed"
|
||||||
|
PointerMoved="OnRowPointerMoved"
|
||||||
|
PointerReleased="OnRowPointerReleased"
|
||||||
|
DragDrop.AllowDrop="True"
|
||||||
|
DragDrop.DragOver="OnRowDragOver"
|
||||||
|
DragDrop.Drop="OnRowDrop">
|
||||||
<islands:TaskRowView/>
|
<islands:TaskRowView/>
|
||||||
</Button>
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -1,17 +1,150 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
public partial class TasksIslandView : UserControl
|
public partial class TasksIslandView : UserControl
|
||||||
{
|
{
|
||||||
|
private static readonly DataFormat<string> TaskRowFormat =
|
||||||
|
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
|
||||||
|
|
||||||
public TasksIslandView()
|
public TasksIslandView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
// Tunnel handler runs BEFORE Button's class handler so we can start a drag
|
||||||
|
// without the Button first marking the event as handled.
|
||||||
|
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
if (DataContext is TasksIslandViewModel vm)
|
if (DataContext is TasksIslandViewModel vm)
|
||||||
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TasksIslandViewModel vm || !vm.CanReorder) return;
|
||||||
|
if (e.Source is not Visual src) return;
|
||||||
|
|
||||||
|
var button = src as Button ?? src.FindAncestorOfType<Button>();
|
||||||
|
if (button?.DataContext is not TaskRowViewModel row) return;
|
||||||
|
if (row.IsRunning) return;
|
||||||
|
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
|
||||||
|
|
||||||
|
var data = new DataTransfer();
|
||||||
|
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
vm.ClearDropHints();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRowPointerPressed(object? sender, PointerPressedEventArgs e) { }
|
||||||
|
private void OnRowPointerMoved(object? sender, PointerEventArgs e) { }
|
||||||
|
private void OnRowPointerReleased(object? sender, PointerReleasedEventArgs e) { }
|
||||||
|
|
||||||
|
private void OnRowDragOver(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
|
||||||
|
if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
vm.ClearDropHints();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
vm.ClearDropHints();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
|
||||||
|
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
vm.ClearDropHints();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
||||||
|
|
||||||
|
// Canonicalize: "drop below X" == "drop above X+1". Render the indicator
|
||||||
|
// above X+1 when there is one; only the last row in a section shows a below-line.
|
||||||
|
TaskRowViewModel hintRow = target;
|
||||||
|
bool hintBelow = false;
|
||||||
|
if (placeBelow)
|
||||||
|
{
|
||||||
|
var next = FindNextInSameSection(vm, target);
|
||||||
|
if (next is not null && !next.IsRunning)
|
||||||
|
{
|
||||||
|
hintRow = next;
|
||||||
|
hintBelow = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hintRow = target;
|
||||||
|
hintBelow = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hint that lands right where the dragged row already sits is a no-op.
|
||||||
|
if (hintRow.Id == sourceId)
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
vm.ClearDropHints();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.SetDropHint(hintRow, hintBelow);
|
||||||
|
e.DragEffects = DragDropEffects.Move;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
|
||||||
|
{
|
||||||
|
var idx = section.IndexOf(row);
|
||||||
|
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRowDrop(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TasksIslandViewModel vm) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (sender is not Button b || b.DataContext is not TaskRowViewModel target) return;
|
||||||
|
if (target.IsRunning) return;
|
||||||
|
|
||||||
|
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
|
||||||
|
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
|
||||||
|
|
||||||
|
var source = FindRowById(vm, sourceId);
|
||||||
|
if (source is null || source.IsRunning) return;
|
||||||
|
|
||||||
|
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
||||||
|
await vm.ReorderAsync(source, target, placeBelow);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
vm.ClearDropHints();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskRowViewModel? FindRowById(TasksIslandViewModel vm, string id)
|
||||||
|
{
|
||||||
|
foreach (var r in vm.Items)
|
||||||
|
if (r.Id == id) return r;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,96 @@ public sealed class TaskRepositoryTests : IDisposable
|
|||||||
Assert.Null(after.Result);
|
Assert.Null(after.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_AssignsDense_PerList_SortOrder()
|
||||||
|
{
|
||||||
|
var listA = await CreateListAsync();
|
||||||
|
var listB = await CreateListAsync();
|
||||||
|
|
||||||
|
var a0 = MakeTask(listA); await _tasks.AddAsync(a0);
|
||||||
|
var a1 = MakeTask(listA); await _tasks.AddAsync(a1);
|
||||||
|
var b0 = MakeTask(listB); await _tasks.AddAsync(b0);
|
||||||
|
var a2 = MakeTask(listA); await _tasks.AddAsync(a2);
|
||||||
|
|
||||||
|
var reloadA0 = await _tasks.GetByIdAsync(a0.Id);
|
||||||
|
var reloadA1 = await _tasks.GetByIdAsync(a1.Id);
|
||||||
|
var reloadA2 = await _tasks.GetByIdAsync(a2.Id);
|
||||||
|
var reloadB0 = await _tasks.GetByIdAsync(b0.Id);
|
||||||
|
|
||||||
|
Assert.Equal(0, reloadA0!.SortOrder);
|
||||||
|
Assert.Equal(1, reloadA1!.SortOrder);
|
||||||
|
Assert.Equal(2, reloadA2!.SortOrder);
|
||||||
|
Assert.Equal(0, reloadB0!.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByListIdAsync_OrdersBy_SortOrder()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var t0 = MakeTask(listId); await _tasks.AddAsync(t0);
|
||||||
|
var t1 = MakeTask(listId); await _tasks.AddAsync(t1);
|
||||||
|
var t2 = MakeTask(listId); await _tasks.AddAsync(t2);
|
||||||
|
|
||||||
|
await _tasks.ReorderAsync(listId, new[] { t2.Id, t0.Id, t1.Id });
|
||||||
|
|
||||||
|
var list = await _tasks.GetByListIdAsync(listId);
|
||||||
|
Assert.Equal(new[] { t2.Id, t0.Id, t1.Id }, list.Select(t => t.Id).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReorderAsync_Renumbers_Dense()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var t0 = MakeTask(listId); await _tasks.AddAsync(t0);
|
||||||
|
var t1 = MakeTask(listId); await _tasks.AddAsync(t1);
|
||||||
|
var t2 = MakeTask(listId); await _tasks.AddAsync(t2);
|
||||||
|
|
||||||
|
await _tasks.ReorderAsync(listId, new[] { t1.Id, t2.Id, t0.Id });
|
||||||
|
|
||||||
|
var r0 = await _tasks.GetByIdAsync(t0.Id);
|
||||||
|
var r1 = await _tasks.GetByIdAsync(t1.Id);
|
||||||
|
var r2 = await _tasks.GetByIdAsync(t2.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, r0!.SortOrder);
|
||||||
|
Assert.Equal(0, r1!.SortOrder);
|
||||||
|
Assert.Equal(1, r2!.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReorderAsync_IgnoresIds_FromOtherLists()
|
||||||
|
{
|
||||||
|
var listA = await CreateListAsync();
|
||||||
|
var listB = await CreateListAsync();
|
||||||
|
var a0 = MakeTask(listA); await _tasks.AddAsync(a0);
|
||||||
|
var b0 = MakeTask(listB); await _tasks.AddAsync(b0);
|
||||||
|
|
||||||
|
// b0 does not belong to listA and should not be renumbered there.
|
||||||
|
await _tasks.ReorderAsync(listA, new[] { b0.Id, a0.Id });
|
||||||
|
|
||||||
|
var reloadB = await _tasks.GetByIdAsync(b0.Id);
|
||||||
|
Assert.Equal(0, reloadB!.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextQueuedAgentTaskAsync_Picks_ByUserSortOrder()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
|
await _lists.AddTagAsync(listId, agentTagId);
|
||||||
|
|
||||||
|
// created in order first, second; then user reorders to put second on top.
|
||||||
|
var first = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10));
|
||||||
|
var second = MakeTask(listId, createdAt: DateTime.UtcNow);
|
||||||
|
await _tasks.AddAsync(first);
|
||||||
|
await _tasks.AddAsync(second);
|
||||||
|
|
||||||
|
await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id });
|
||||||
|
|
||||||
|
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||||
|
Assert.NotNull(picked);
|
||||||
|
Assert.Equal(second.Id, picked!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user