Compare commits
2
Commits
4dc4fe27e3
...
ef7645c6b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7645c6b1 | ||
|
|
92ce7a4a77 |
@@ -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
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user