From 92ce7a4a77b4aa6896c721eb8d402f0144128663 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 29 Jul 2026 15:10:59 +0200 Subject: [PATCH] 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. --- .../Repositories/TaskRepository.cs | 54 +++ src/ClaudeDo.Localization/locales/de.json | 2 +- src/ClaudeDo.Localization/locales/en.json | 2 +- src/ClaudeDo.Ui/Design/IslandStyles.axaml | 9 + .../Islands/ListNavItemViewModel.cs | 3 + .../Islands/TasksIslandViewModel.cs | 75 ++++ .../ViewModels/IslandsShellViewModel.cs | 1 + .../Views/Islands/ListsIslandView.axaml | 1 + .../Views/Islands/TasksIslandView.axaml.cs | 58 ++- tests/ClaudeDo.Data.Tests/MoveToListTests.cs | 143 ++++++++ .../ViewModels/TasksIslandMoveToListTests.cs | 346 ++++++++++++++++++ 11 files changed, 690 insertions(+), 4 deletions(-) create mode 100644 tests/ClaudeDo.Data.Tests/MoveToListTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index e68e57f4..b3e48255 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -84,6 +84,60 @@ public sealed class TaskRepository public Task> GetByListAsync(string listId, CancellationToken ct = default) => GetByListIdAsync(listId, ct); + /// + /// Returns the ids of every descendant of (children, grandchildren, ...), + /// walking the ParentTaskId chain breadth-first. Does not include the task itself. + /// + public async Task> GetDescendantIdsAsync(string taskId, CancellationToken ct = default) + { + var result = new List(); + // Guards against a corrupt parent chain (a self-parent or a cycle would loop forever). + var seen = new HashSet(StringComparer.Ordinal) { taskId }; + var frontier = new List { 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; + } + + /// + /// Moves a task (and every descendant, so a child never ends up in a different list than its + /// parent) to , 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. + /// + 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 { 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> GetByCreatorAsync(string createdBy, CancellationToken ct = default) { return await _context.Tasks diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index d63a6441..d6b425f9 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3f86548b..86db406c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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}" }, diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index 577515c4..da7c8242 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -693,6 +693,10 @@ + + + + +