diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index a2d4cbf..0c1be42 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -51,6 +51,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration 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.Notes).HasColumnName("notes"); + builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.HasOne(t => t.List) .WithMany(l => l.Tasks) @@ -74,5 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); + builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); } } diff --git a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs new file mode 100644 index 0000000..6918635 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddTaskSortOrder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "idx_tasks_list_sort", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "sort_order", + table: "tasks"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 66256f8..eee421b 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -281,6 +281,12 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("scheduled_for"); + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + b.Property("StartedAt") .HasColumnType("TEXT") .HasColumnName("started_at"); @@ -307,6 +313,9 @@ namespace ClaudeDo.Data.Migrations b.HasIndex("Status") .HasDatabaseName("idx_tasks_status"); + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + b.ToTable("tasks", (string)null); }); diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 9041dbe..3ddb378 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -29,6 +29,7 @@ public sealed class TaskEntity public bool IsStarred { get; set; } public bool IsMyDay { get; set; } public string? Notes { get; set; } + public int SortOrder { get; set; } // Navigation properties public ListEntity List { get; set; } = null!; diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 24bab92..b15ef15 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -14,6 +14,13 @@ public sealed class TaskRepository 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); await _context.SaveChangesAsync(ct); } @@ -38,10 +45,32 @@ public sealed class TaskRepository { return await _context.Tasks .Where(t => t.ListId == listId) - .OrderBy(t => t.CreatedAt) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); } + /// + /// Renumbers tasks in a list to 0..N-1 according to . + /// Ids not belonging to the list are ignored; ids missing from the list are untouched. + /// + public async Task ReorderAsync(string listId, IReadOnlyList 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. public Task> GetByListAsync(string listId, CancellationToken ct = default) => GetByListIdAsync(listId, ct); @@ -205,7 +234,7 @@ public sealed class TaskRepository 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 ) RETURNING * diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index c308201..86fd062 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -272,6 +272,18 @@ + + + diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 4010c3d..831fda7 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -21,6 +21,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private DateTime? _scheduledFor; [ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffDeletions; + [ObservableProperty] private bool _dropHintAbove; + [ObservableProperty] private bool _dropHintBelow; public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 7136260..da8c2ee 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -75,6 +75,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var all = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); ct.ThrowIfCancellationRequested(); @@ -149,23 +150,83 @@ public sealed partial class TasksIslandViewModel : ViewModelBase { if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return; 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 { Id = Guid.NewGuid().ToString("N"), ListId = listId, Title = NewTaskTitle.Trim(), CreatedAt = DateTime.UtcNow, + SortOrder = (maxSort ?? -1) + 1, }; - await using var db = await _dbFactory.CreateDbContextAsync(); db.Tasks.Add(entity); await db.SaveChangesAsync(); var row = TaskRowViewModel.FromEntity(entity); - Items.Insert(0, row); + Items.Add(row); Regroup(); NewTaskTitle = ""; 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] private async Task ToggleDoneAsync(TaskRowViewModel row) { diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 3fe4d04..1ae01d4 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -3,10 +3,23 @@ xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView" x:DataType="vm:TaskRowViewModel"> - - + + + + + + + + + + + + + + + + + + - + diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml index 4ba0510..0a272bf 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml @@ -80,7 +80,13 @@ @@ -99,7 +105,13 @@ @@ -123,7 +135,13 @@ diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs index 9aff67e..9f53c8d 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs @@ -1,17 +1,150 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Views.Islands; public partial class TasksIslandView : UserControl { + private static readonly DataFormat TaskRowFormat = + DataFormat.CreateStringApplicationFormat("claudedo-task-row"); + public TasksIslandView() { 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 += (_, _) => { if (DataContext is TasksIslandViewModel vm) 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