diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 8c54fc16..4436139a 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -167,6 +167,7 @@ "ctxMarkDone": "Erledigt", "ctxMarkCancelled": "Abgebrochen", "ctxOpenConPtySession": "Interaktive Sitzung öffnen", + "ctxOpenWorktree": "Worktree öffnen", "ctxOpenPlanningSession": "Planungssitzung öffnen", "ctxResumePlanningSession": "Planungssitzung fortsetzen", "ctxFinalizePlanningSession": "Plan finalisieren", @@ -199,6 +200,7 @@ "reasonIsPlanningParent": "Teil eines Plans", "reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung", "reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert", + "reasonNoWorktree": "Kein Worktree für diesen Task", "approve": "Genehmigen", "approveTip": "Genehmigen — als Erledigt markieren", "reject": "Ablehnen", @@ -719,7 +721,7 @@ "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." }, - "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "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.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}" }, + "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "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.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "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": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({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}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "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 e362dab0..bbe5aecb 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -167,6 +167,7 @@ "ctxMarkDone": "Done", "ctxMarkCancelled": "Cancelled", "ctxOpenConPtySession": "Open interactive session", + "ctxOpenWorktree": "Open worktree", "ctxOpenPlanningSession": "Open planning Session", "ctxResumePlanningSession": "Resume planning Session", "ctxFinalizePlanningSession": "Finalize plan", @@ -199,6 +200,7 @@ "reasonIsPlanningParent": "Part of a plan", "reasonIsChildTask": "Subtasks don't have their own planning session", "reasonPlanAlreadyFinalized": "Plan is already finalized", + "reasonNoWorktree": "No worktree for this task", "approve": "Approve", "approveTip": "Approve — mark Done", "reject": "Reject", @@ -719,7 +721,7 @@ "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." }, - "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "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.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}" }, + "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "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.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" }, "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": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({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}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index cd35ea5f..00593aa9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -20,6 +20,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private string? _branch; [ObservableProperty] private string? _diffStat; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState; + [ObservableProperty] private string? _worktreePath; [ObservableProperty] private DateTime? _scheduledFor; [ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffDeletions; @@ -179,6 +180,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase // placeholder so the menu wiring is uniform; always null until a real gate exists. public string? QuickSessionDisabledReason => null; + public bool CanOpenWorktree => !string.IsNullOrWhiteSpace(WorktreePath); + public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree"); + public string? RefineDisabledReason { get @@ -425,6 +429,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase partial void OnNumberChanged(int value) => OnPropertyChanged(nameof(ShowNumberBadge)); partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); + partial void OnWorktreePathChanged(string? value) + { + OnPropertyChanged(nameof(CanOpenWorktree)); + OnPropertyChanged(nameof(OpenWorktreeDisabledReason)); + } partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value) { OnPropertyChanged(nameof(IsParked)); @@ -485,6 +494,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase Branch = t.Worktree?.BranchName; DiffStat = t.Worktree?.DiffStat; WorktreeState = t.Worktree?.State; + WorktreePath = t.Worktree?.Path; ScheduledFor = t.ScheduledFor; DiffAdditions = add; DiffDeletions = del; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 17d1920f..1d1704da 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -1429,6 +1429,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable OpenConPtySessionRequested?.Invoke(row.Id); } + [RelayCommand] + private void OpenTaskWorktree(TaskRowViewModel? row) + { + if (row?.WorktreePath is not { } path || string.IsNullOrWhiteSpace(path)) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }); + } + catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", ex.Message)); } + } + [RelayCommand] private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) { diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index cc733aa1..274d48b4 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -61,6 +61,7 @@ public partial class TaskRowView : UserControl menu.Items.Add(removeFromQueueItem); menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, reason: row.CancelDisabledReason)); menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick, reason: row.QuickSessionDisabledReason)); + menu.Items.Add(MakeItem("tasks.ctxOpenWorktree", OnOpenWorktreeClick, reason: row.OpenWorktreeDisabledReason)); menu.Items.Add(MakeItem("tasks.ctxRefineTask", OnRefineTaskClick, reason: row.RefineDisabledReason)); menu.Items.Add(new Separator()); @@ -169,6 +170,12 @@ public partial class TaskRowView : UserControl vm.OpenConPtySessionCommand.Execute(row); } + private void OnOpenWorktreeClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + vm.OpenTaskWorktreeCommand.Execute(row); + } + // The status chip is only actionable while a ConPTY session is open; the open command dedupes // by task id, so it surfaces Mission Control and focuses the existing pane. private void OnStatusChipTapped(object? sender, RoutedEventArgs e) diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs index d25486e1..016f2d84 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs @@ -64,6 +64,60 @@ public class TaskRowViewModelTests Assert.Contains(nameof(TaskRowViewModel.ShowNumberBadge), raised); } + [Fact] + public void CanOpenWorktree_True_And_ReasonNull_When_Path_Present() + { + var vm = new TaskRowViewModel { Id = "t" }; + vm.WorktreePath = @"C:\repo\worktrees\t"; + + Assert.True(vm.CanOpenWorktree); + Assert.Null(vm.OpenWorktreeDisabledReason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CanOpenWorktree_False_And_ReasonSet_When_Path_Missing(string? path) + { + var vm = new TaskRowViewModel { Id = "t" }; + vm.WorktreePath = path; + + Assert.False(vm.CanOpenWorktree); + Assert.NotNull(vm.OpenWorktreeDisabledReason); + } + + [Fact] + public void OpenWorktreeGate_Raises_PropertyChanged_On_WorktreePath_Change() + { + var vm = new TaskRowViewModel { Id = "t" }; + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + vm.WorktreePath = @"C:\repo\worktrees\t"; + + Assert.Contains(nameof(TaskRowViewModel.CanOpenWorktree), raised); + Assert.Contains(nameof(TaskRowViewModel.OpenWorktreeDisabledReason), raised); + } + + [Fact] + public void WorktreePath_SetFromEntity_MapsFromWorktreePath() + { + var entity = new TaskEntity + { + Id = "t", ListId = "l1", Title = "Task", CreatedAt = DateTime.UtcNow, + Worktree = new WorktreeEntity + { + TaskId = "t", Path = @"C:\repo\worktrees\t", BranchName = "b", + BaseCommit = "abc123", CreatedAt = DateTime.UtcNow, + }, + }; + var vm = TaskRowViewModel.FromEntity(entity); + + Assert.Equal(@"C:\repo\worktrees\t", vm.WorktreePath); + Assert.True(vm.CanOpenWorktree); + } + [Fact] public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged() {