Compare commits

...
2 Commits
Author SHA1 Message Date
Mika Kuns ef7645c6b1 Merge claudedo/a6a2df8848a8460aa1fe57c1b69aff47
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 38s
2026-07-29 15:11:35 +02:00
Mika Kuns 92ce7a4a77 feat(ui): move a task to another list via drag & drop
Dragging a task row onto a user list in the Lists island now reassigns it
to that list. The task drag holds the pointer capture, so the release is
resolved geometrically (new case between the Mission Control and reorder
cases) instead of going through the Lists island's own DragDrop path,
which never sees a DragEventArgs during a task drag.

TaskRepository.MoveToListAsync reassigns the task plus every descendant
(a child must never sit in a different list than its parent) and appends
the task at the end of the target list. Guards: running tasks and tasks
holding an Active/Kept worktree are rejected to the footer error strip;
a move that changes repo asks for confirmation naming both repos.

The source repo is read from the task's own list rather than the island's
current list, which is a smart/virtual list with no working dir of its own
whenever one of those is shown.
2026-07-29 15:10:59 +02:00
11 changed files with 690 additions and 4 deletions
@@ -84,6 +84,60 @@ public sealed class TaskRepository
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct);
/// <summary>
/// Returns the ids of every descendant of <paramref name="taskId"/> (children, grandchildren, ...),
/// walking the ParentTaskId chain breadth-first. Does not include the task itself.
/// </summary>
public async Task<List<string>> GetDescendantIdsAsync(string taskId, CancellationToken ct = default)
{
var result = new List<string>();
// Guards against a corrupt parent chain (a self-parent or a cycle would loop forever).
var seen = new HashSet<string>(StringComparer.Ordinal) { taskId };
var frontier = new List<string> { taskId };
while (frontier.Count > 0)
{
var children = await _context.Tasks.AsNoTracking()
.Where(t => t.ParentTaskId != null && frontier.Contains(t.ParentTaskId))
.Select(t => t.Id)
.ToListAsync(ct);
var fresh = children.Where(seen.Add).ToList();
if (fresh.Count == 0) break;
result.AddRange(fresh);
frontier = fresh;
}
return result;
}
/// <summary>
/// Moves a task (and every descendant, so a child never ends up in a different list than its
/// parent) to <paramref name="targetListId"/>, appending it at the end of the target list's
/// order. ListId is init-only, so the move goes through ExecuteUpdate rather than a tracked
/// entity mutation.
/// </summary>
public async Task MoveToListAsync(string taskId, string targetListId, CancellationToken ct = default)
{
var exists = await _context.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct);
if (!exists)
throw new InvalidOperationException($"Task {taskId} not found.");
var descendantIds = await GetDescendantIdsAsync(taskId, ct);
var movedIds = new List<string> { taskId };
movedIds.AddRange(descendantIds);
var maxSort = await _context.Tasks
.Where(t => t.ListId == targetListId)
.Select(t => (int?)t.SortOrder)
.MaxAsync(ct);
await _context.Tasks
.Where(t => movedIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ListId, targetListId), ct);
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, (maxSort ?? -1) + 1), ct);
}
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
{
return await _context.Tasks
+1 -1
View File
@@ -542,7 +542,7 @@
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
+1 -1
View File
@@ -542,7 +542,7 @@
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
@@ -693,6 +693,10 @@
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" />
<!-- Reserved so the task-drop highlight below only swaps the brush — a thickness that changes
on hover would nudge the row's content while dragging over it. -->
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -700,6 +704,11 @@
<Style Selector="Border.list-item.active">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style>
<!-- Highlighted while a dragged task hovers over this row as a move target. -->
<Style Selector="Border.list-item.drop-target">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
@@ -16,6 +16,9 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isManual;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
// Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; }
public string? DotColorKey { get; init; }
}
@@ -471,6 +471,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
public string? CurrentListId => _currentList?.Id;
/// <summary>Set by the shell (mirrors <see cref="ListsIslandViewModel.Dialogs"/>) so a
/// cross-repo move can confirm via the shared modal seam.</summary>
public IDialogService? Dialogs { get; set; }
public void ClearDropHints()
{
@@ -573,6 +578,76 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
return null;
}
/// <summary>
/// Drag-drop move: reassigns a task (and every descendant) to <paramref name="targetList"/>.
/// No-op for anything that isn't a user list the task doesn't already sit in.
/// Rejects running tasks and tasks (or descendants) holding an Active/Kept worktree — the
/// worktree would keep pointing at the source repo. A move across repos without a worktree
/// is allowed but asks for confirmation first.
/// </summary>
public async Task MoveTaskToListAsync(TaskRowViewModel row, ListNavItemViewModel targetList)
{
if (targetList.Kind != ListKind.User) return;
var targetListId = targetList.Id.StartsWith("user:", StringComparison.Ordinal)
? targetList.Id["user:".Length..]
: targetList.Id;
await using var db = await _dbFactory.CreateDbContextAsync();
// The row's own list, not _currentList — the task island also shows smart/virtual lists,
// whose rows come from many lists and which carry no working dir of their own. Dropping a
// task on the list it already sits in is a silent no-op.
var sourceListId = await db.Tasks.AsNoTracking()
.Where(t => t.Id == row.Id).Select(t => t.ListId).FirstOrDefaultAsync();
if (sourceListId is null || sourceListId == targetListId) return;
if (row.IsRunning)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveRunningRejected"));
return;
}
var repo = new TaskRepository(db);
var descendantIds = await repo.GetDescendantIdsAsync(row.Id);
var ownIds = new List<string> { row.Id };
ownIds.AddRange(descendantIds);
var hasBlockingWorktree = await db.Worktrees.AsNoTracking()
.Where(w => ownIds.Contains(w.TaskId)
&& (w.State == ClaudeDo.Data.Models.WorktreeState.Active
|| w.State == ClaudeDo.Data.Models.WorktreeState.Kept))
.AnyAsync();
if (hasBlockingWorktree)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected"));
return;
}
var sourceDir = await db.Lists.AsNoTracking()
.Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync();
var targetDir = targetList.WorkingDir;
var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase);
if (repoChanges)
{
if (Dialogs is null)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable"));
return;
}
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm",
string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir,
string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir));
if (!ok) return;
}
await repo.MoveToListAsync(row.Id, targetListId);
Items.Remove(row);
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row)
{
@@ -56,6 +56,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
}
}
@@ -116,6 +116,7 @@
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
@@ -31,6 +31,9 @@ public partial class TasksIslandView : UserControl
private bool _dragArmed;
private bool _dragging;
// The list row (in the Lists island) currently highlighted as a drop target while dragging.
private ListNavItemViewModel? _hintedList;
public TasksIslandView()
{
InitializeComponent();
@@ -170,6 +173,7 @@ public partial class TasksIslandView : UserControl
{
_drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
UpdateReorderHint(e, topLevel);
UpdateListDropHint(e, topLevel);
}
}
@@ -205,7 +209,17 @@ public partial class TasksIslandView : UserControl
return;
}
// 2) Released over another row in the same user list → reorder.
// 2) Released over another user list in the Lists island → move the task there.
if (DataContext is TasksIslandViewModel vmMove
&& ListItemUnder(e, topLevel) is { } targetList
&& targetList.Kind == ListKind.User
&& targetList.Id != vmMove.CurrentListId)
{
await vmMove.MoveTaskToListAsync(row, targetList);
return;
}
// 3) Released over another row in the same user list → reorder.
if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
{
var targetButton = RowButtonAt(e, topLevel);
@@ -218,7 +232,7 @@ public partial class TasksIslandView : UserControl
}
}
// 3) Anywhere else → cancel; EndDrag already restored the source row.
// 4) Anywhere else → cancel; EndDrag already restored the source row.
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
@@ -229,6 +243,7 @@ public partial class TasksIslandView : UserControl
if (!_dragArmed && !_dragging) return;
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
ResetPressState();
}
@@ -237,6 +252,7 @@ public partial class TasksIslandView : UserControl
{
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
if (_dragging) e.Pointer.Capture(null);
ResetPressState();
@@ -295,6 +311,44 @@ public partial class TasksIslandView : UserControl
return button?.DataContext is TaskRowViewModel ? button : null;
}
// The list-row Border under the cursor (Lists island), found the same way as RowButtonAt but
// walking up to a Border with the "list-item" style class (mirrors ListsIslandView's own
// FindListItemBorder) — the task drag has the pointer captured here, so a DragEventArgs never
// reaches the Lists island's own DragDrop.DragOver/Drop handlers.
private static ListNavItemViewModel? ListItemUnder(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
Visual? v = topLevel.InputHitTest(pt) as Visual;
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item"))
return b.DataContext as ListNavItemViewModel;
v = v.GetVisualParent();
}
return null;
}
// Live drop-hint while dragging over a candidate target list row.
private void UpdateListDropHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) { ClearListDropHint(); return; }
var target = ListItemUnder(e, topLevel);
if (target is null || target.Kind != ListKind.User || target.Id == vm.CurrentListId)
target = null;
if (ReferenceEquals(_hintedList, target)) return;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = target;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = true;
}
private void ClearListDropHint()
{
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = null;
}
// The Mission Control view model whose window contains the release point, if any.
private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
{
@@ -0,0 +1,143 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests;
public sealed class MoveToListTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
public MoveToListTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movelist_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
}
public void Dispose()
{
_ctx.Dispose();
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private async Task SeedListsAsync(params string[] ids)
{
foreach (var id in ids)
_ctx.Lists.Add(new ListEntity { Id = id, Name = id, CreatedAt = DateTime.UtcNow });
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task MoveToList_changes_ListId_and_appends_at_end_of_target()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
// Existing tasks already in the target list, so we can assert the moved task lands after them.
_ctx.Tasks.Add(new TaskEntity
{
Id = "existing1", ListId = "target", Title = "Existing 1", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "existing2", ListId = "target", Title = "Existing 2", CreatedAt = DateTime.UtcNow, SortOrder = 1,
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
Assert.Equal(2, moved.SortOrder);
}
[Fact]
public async Task MoveToList_moves_all_descendants_recursively()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "parent", ListId = "source", Title = "Parent", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "child", ListId = "source", Title = "Child", CreatedAt = DateTime.UtcNow,
SortOrder = 1, ParentTaskId = "parent",
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "grandchild", ListId = "source", Title = "Grandchild", CreatedAt = DateTime.UtcNow,
SortOrder = 2, ParentTaskId = "child",
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("parent", "target");
var all = await _ctx.Tasks.AsNoTracking()
.Where(t => t.Id == "parent" || t.Id == "child" || t.Id == "grandchild")
.ToListAsync();
Assert.All(all, t => Assert.Equal("target", t.ListId));
}
[Fact]
public async Task MoveToList_first_task_in_empty_target_gets_SortOrder_zero()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 3,
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
Assert.Equal(0, moved.SortOrder);
}
// A corrupt parent chain must not hang the descendant walk.
[Fact(Timeout = 15000)]
public async Task MoveToList_survives_a_self_parenting_task()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
await _ctx.SaveChangesAsync();
// Set the cycle after insert — the FK tolerates it, the walk must too.
await _ctx.Tasks.Where(t => t.Id == "t1")
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, "t1"));
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
}
[Fact]
public async Task MoveToList_throws_when_task_missing()
{
await SeedListsAsync("source", "target");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new TaskRepository(_ctx).MoveToListAsync("nope", "target"));
}
}
@@ -0,0 +1,346 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandMoveToListTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandMoveToListTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movetolist_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class FakeDialogService : IDialogService
{
public bool ConfirmResult;
public int ConfirmCalls;
public string? LastMessage;
public Task<bool> ConfirmAsync(string message)
{
ConfirmCalls++;
LastMessage = message;
return Task.FromResult(ConfirmResult);
}
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed) => throw new NotImplementedException();
}
private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null);
private async Task SeedListsAsync(string? sourceDir = null, string? targetDir = null)
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "source", Name = "Source", CreatedAt = DateTime.UtcNow, WorkingDir = sourceDir });
db.Lists.Add(new ListEntity { Id = "target", Name = "Target", CreatedAt = DateTime.UtcNow, WorkingDir = targetDir });
await db.SaveChangesAsync();
}
private async Task SeedTaskAsync(string id, TaskStatus status, string listId = "source", string? parentTaskId = null)
{
await using var db = NewContext();
db.Tasks.Add(new TaskEntity
{
Id = id, ListId = listId, Title = id, CreatedAt = DateTime.UtcNow,
Status = status, ParentTaskId = parentTaskId, SortOrder = 0,
});
await db.SaveChangesAsync();
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
{
await using var db = NewContext();
db.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = $"C:/wt/{taskId}", BranchName = $"task/{taskId}",
BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
private static ListNavItemViewModel SourceList(string? workingDir = null) =>
new() { Id = "user:source", Kind = ListKind.User, Name = "Source", WorkingDir = workingDir };
private static ListNavItemViewModel TargetList(string? workingDir = null) =>
new() { Id = "user:target", Kind = ListKind.User, Name = "Target", WorkingDir = workingDir };
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
{
vm.LoadForList(list);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(25);
if (vm.Items.Count > 0) break;
}
await Task.Delay(50);
}
private async Task<TaskEntity> GetTaskAsync(string id)
{
await using var db = NewContext();
return await db.Tasks.AsNoTracking().FirstAsync(t => t.Id == id);
}
[Fact]
public async Task Move_RunningTask_IsRejected_AndReportsError()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Running);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.NotNull(error);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_TaskWithActiveWorktree_IsRejected_AndReportsError_EvenSameRepo()
{
var dir = "C:/repo";
await SeedListsAsync(dir, dir);
await SeedTaskAsync("t1", TaskStatus.Idle);
await SeedWorktreeAsync("t1", WorktreeState.Active);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList(dir));
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList(dir));
Assert.NotNull(error);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_DescendantWithKeptWorktree_IsRejected()
{
await SeedListsAsync();
await SeedTaskAsync("parent", TaskStatus.Idle);
await SeedTaskAsync("child", TaskStatus.Idle, parentTaskId: "parent");
await SeedWorktreeAsync("child", WorktreeState.Kept);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "parent");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.NotNull(error);
var entity = await GetTaskAsync("parent");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_SameWorkingDir_MovesWithoutConfirmDialog()
{
var dir = "C:/repo";
await SeedListsAsync(dir, dir);
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList(dir));
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList(dir));
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_BothWithoutWorkingDir_MovesWithoutConfirmDialog()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_AsksConfirmation_MovesOnConfirm()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = true };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.Equal(1, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_CancelledConfirmation_DoesNotMove()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = false };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.Equal(1, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_TargetIsCurrentList_IsNoOp()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, SourceList());
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_ConfirmMessageNamesBothRepos()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = false };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.NotNull(dialogs.LastMessage);
Assert.Contains("C:/repoA", dialogs.LastMessage);
Assert.Contains("C:/repoB", dialogs.LastMessage);
}
// The source repo comes from the task's own list, not from the island's current list — those
// differ whenever a smart/virtual list is shown, and a wrong source dir would ask (or skip
// asking) for confirmation at the wrong times.
[Fact]
public async Task Move_SourceRepoIsReadFromTaskOwnList_NotCurrentList()
{
await SeedListsAsync("C:/repoA", "C:/repoA");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
// Island shows a virtual list carrying no working dir of its own.
await LoadAndWaitAsync(vm, SourceList(workingDir: null));
var dialogs = new FakeDialogService { ConfirmResult = true };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoA"));
// Both lists really are the same repo, so no confirmation should have been needed.
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_TargetIsSmartList_IsNoOp()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var row = vm.Items.First(r => r.Id == "t1");
var smart = new ListNavItemViewModel { Id = "smart:my-day", Kind = ListKind.Smart, Name = "My Day" };
await vm.MoveTaskToListAsync(row, smart);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
}