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 @@ + + + + +