From 86cc309ba9755321b86e772749b621a8f490eb8b Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 16 Sep 2026 12:26:21 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20duplizierter=20Confirm-Dialog=20mit?= =?UTF-8?q?=20"Delete"-Knopf=20=E2=80=93=20zusammenfuehren=20und=20lokalis?= =?UTF-8?q?ieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4: DetailsIslandView/TasksIslandView hatten je eine view-lokale Confirm/Error-Dialog-Kopie, teils mit hartkodiertem "Delete"-Knopf fuer jede Aktion (inkl. der Stash+Merge-Bestaetigung, die nichts loescht). Beide Kopien entfernt; DetailsIslandViewModel/TasksIslandViewModel routen Confirm/Error jetzt durch den geteilten Dialogs-Seam (IDialogService, propagiert vom Shell wie schon bei Lists/Tasks) statt ueber eine vom Code-behind gesetzte Func-Property. IDialogService.ConfirmAsync bekommt einen optionalen confirmLabel-Parameter (Default: lokalisiertes "Confirm"); nur die echten Delete-Aktionen (Task loeschen, abgeschlossene Tasks leeren) uebergeben das lokalisierte "Delete"-Label. F5: "Cancel"/"Confirm"/"Delete"/"OK" und die Titel "Confirm"/"Error" durch Loc.T mit neuen Keys unter "dialog.*" ersetzt (en.json + de.json). Mission Controls eigene Dialog-Kopie bleibt bestehen (eigenes Fenster, WindowDialogService ist an den Haupt-Owner gebunden) — nur ihre Labels wurden lokalisiert. Out of scope (siehe Task-Beschreibung): die Nachrichtentexte der Dialoge bleiben unlokalisiert. --- src/ClaudeDo.Localization/locales/de.json | 8 ++ src/ClaudeDo.Localization/locales/en.json | 8 ++ src/ClaudeDo.Ui/Services/IDialogService.cs | 5 +- .../Islands/DetailsIslandViewModel.cs | 50 ++++++----- .../Islands/TasksIslandViewModel.cs | 21 ++--- .../ViewModels/IslandsShellViewModel.cs | 3 +- .../Views/Islands/DetailsIslandView.axaml.cs | 85 ------------------- .../Views/Islands/TasksIslandView.axaml.cs | 45 ---------- .../MissionControlView.axaml.cs | 12 +-- src/ClaudeDo.Ui/Views/WindowDialogService.cs | 19 +++-- .../IslandsShellViewModelShutdownTests.cs | 2 +- tests/ClaudeDo.Ui.Tests/TestDoubles.cs | 43 ++++++++++ .../DetailsIslandDeleteTaskTests.cs | 12 +-- .../DetailsIslandErrorFeedbackTests.cs | 2 +- .../DetailsIslandReviewActionsTests.cs | 6 +- .../ListsIslandCreateListCancelTests.cs | 2 +- .../ViewModels/TasksIslandMoveToListTests.cs | 2 +- 17 files changed, 126 insertions(+), 199 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7352a2b4..f928db3c 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -805,6 +805,14 @@ "done": "Fertig" } }, + "dialog": { + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "delete": "Löschen", + "ok": "OK", + "confirmTitle": "Bestätigen", + "errorTitle": "Fehler" + }, "shell": { "menu": { "help": "Hilfe", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 48b391d6..d8293800 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -805,6 +805,14 @@ "done": "Done" } }, + "dialog": { + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "ok": "OK", + "confirmTitle": "Confirm", + "errorTitle": "Error" + }, "shell": { "menu": { "help": "Help", diff --git a/src/ClaudeDo.Ui/Services/IDialogService.cs b/src/ClaudeDo.Ui/Services/IDialogService.cs index b047c529..06f8e7b2 100644 --- a/src/ClaudeDo.Ui/Services/IDialogService.cs +++ b/src/ClaudeDo.Ui/Services/IDialogService.cs @@ -28,8 +28,9 @@ public interface IDialogService Task ShowConflictResolverAsync(ConflictResolverViewModel vm); Task ShowLogVisualizerAsync(LogVisualizerViewModel vm); - /// Modal yes/no confirmation. Returns true only when confirmed. - Task ConfirmAsync(string message); + /// Modal yes/no confirmation. Returns true only when confirmed. + /// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions). + Task ConfirmAsync(string message, string? confirmLabel = null); /// Modal error notice with a single dismiss button. Task ShowErrorAsync(string message); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 9b40b0e1..32fb5885 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable // Set by shell so DeleteTaskCommand can remove from list public Func? DeleteFromList { get; set; } - // Set by the view so DeleteTaskCommand can prompt yes/no before deleting - public Func>? ConfirmAsync { get; set; } - - // Set by the view so DeleteTaskCommand can show an error message - public Func? ShowErrorAsync { get; set; } + // Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go + // through the one shared dialog implementation instead of a view-local copy. + public IDialogService? Dialogs { get; set; } // ── Review ────────────────────────────────────────────────────────────── [ObservableProperty] @@ -1042,9 +1040,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { if (Task == null) return; var row = Task; - if (ConfirmAsync != null) + if (Dialogs != null) { - var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone."); + var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete")); if (!ok) return; } // Routed through the worker (mirrors the MCP delete_task tool) so a deleted child @@ -1060,14 +1058,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { // Belt and braces: the connection can drop between the CanExecute check and // this call, so a stray throw here must surface, not vanish silently. - if (ShowErrorAsync != null) - await ShowErrorAsync(ex.Message); + if (Dialogs != null) + await Dialogs.ShowErrorAsync(ex.Message); return; } if (!deleted) { - if (ShowErrorAsync != null) - await ShowErrorAsync(error ?? "Delete failed."); + if (Dialogs != null) + await Dialogs.ShowErrorAsync(error ?? "Delete failed."); return; } if (DeleteFromList != null) @@ -1216,10 +1214,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable private async System.Threading.Tasks.Task ResetAndRetryAsync() { if (Task == null) return; - if (ConfirmAsync == null) return; + if (Dialogs == null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; - var ok = await ConfirmAsync( + var ok = await Dialogs.ConfirmAsync( $"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning."); if (!ok) return; @@ -1279,31 +1277,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable // Nothing landed -- the target working dir has uncommitted changes (often another // session's WIP in the shared main checkout). Offer the one-shot stash retry; git // restores the changes itself once the merge is through. - if (result?.Status == "dirty_target" && ConfirmAsync != null - && await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm"))) + if (result?.Status == "dirty_target" && Dialogs != null + && await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm"))) { ApproveOp.Report(Loc.T("ops.merge.merging")); result = await _worker.ApproveReviewAsync( taskId, Merge.SelectedMergeTarget ?? "", autostash: true); } - if (result?.Status == "dirty_target" && ShowErrorAsync != null) - await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget")); + if (result?.Status == "dirty_target" && Dialogs != null) + await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget")); else if (!hasChildren && result?.Status == "conflict") await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? ""); // The merge itself already landed; the verify command failed, so the task stayed // out of Done. Surface that instead of silently looking like nothing happened. - else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null) - await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed")); + else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null) + await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed")); // Nothing landed -- the merge was refused before touching the repo because the // branch would have overwritten an untracked file in the target working directory. - else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null) - await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision")); + else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null) + await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision")); } catch (Exception ex) { - if (ShowErrorAsync != null) - await ShowErrorAsync(ex.Message); + if (Dialogs != null) + await Dialogs.ShowErrorAsync(ex.Message); } finally { @@ -1348,7 +1346,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable try { await _worker.SubmitTaskForReviewAsync(Task.Id); } catch (Exception ex) { - if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message); + if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message); } } @@ -1379,9 +1377,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [RelayCommand] private async System.Threading.Tasks.Task ResetReviewAsync() { - if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return; + if (Task is null || !_worker.IsConnected || Dialogs is null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; - var ok = await ConfirmAsync( + var ok = await Dialogs.ConfirmAsync( $"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle."); if (!ok) return; try { await _worker.ResetTaskAsync(Task.Id); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 2591ee6f..c604eed9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -1209,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // block removing a completed planning parent together with its done children. var toDelete = completed.OrderByDescending(r => r.IsChild).ToList(); - if (ConfirmAsync is not null) + if (Dialogs is not null) { - var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone."); + var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete")); if (!ok) return; } @@ -1339,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable private async Task DeleteTaskAsync(TaskRowViewModel? row) { if (row is null || row.HasInteractiveSession || _worker is null) return; - if (ConfirmAsync is not null) + if (Dialogs is not null) { - var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title)); + var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete")); if (!ok) return; } @@ -1653,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren) { - if (ConfirmAsync is null) return; - var ok = await ConfirmAsync( + if (Dialogs is null) return; + var ok = await Dialogs.ConfirmAsync( $"{outcome.QueuedChildrenCount} child task(s) are queued.\n" + "Dequeue them and discard the planning session?"); if (!ok) return; @@ -1663,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren) { - if (ConfirmAsync is null) return; - await ConfirmAsync( + if (Dialogs is null) return; + await Dialogs.ConfirmAsync( $"{outcome.RunningChildrenCount} child task(s) are still running.\n" + "Cancel them first, then try again."); } } - /// - /// Wired by the view via . Returns true when the user confirms. - /// - public Func>? ConfirmAsync { get; set; } - private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning; [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))] diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index d7f5b80c..8ee08b02 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -68,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable // Set by MainWindow so a reveal can bring the main window to the foreground. public Action? BringToFront { get; set; } - // Single dialog seam (set by MainWindow); propagated to the lists island. + // Single dialog seam (set by MainWindow); propagated to the islands. private IDialogService? _dialogs; public IDialogService? Dialogs { @@ -78,6 +78,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; + if (Details is not null) Details.Dialogs = value; } } diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs index 4eaa90c2..671c456e 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs @@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl await modal.ShowDialog(owner); }; - vm.ConfirmAsync = ShowConfirmAsync; - vm.ShowErrorAsync = ShowErrorDialogAsync; } } @@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl _descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value; } - private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message) - { - var owner = TopLevel.GetTopLevel(this) as Window; - if (owner == null) return; - - var ok = new Button { Content = "OK", MinWidth = 90 }; - - var dialog = new Window - { - Title = "Error", - Width = 360, - SizeToContent = SizeToContent.Height, - CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - ShowInTaskbar = false, - Background = this.FindResource("SurfaceBrush") as IBrush, - Content = new StackPanel - { - Spacing = 16, - Margin = new Thickness(20), - Children = - { - new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - HorizontalAlignment = HorizontalAlignment.Right, - Children = { ok } - } - } - } - }; - - ok.Click += (_, _) => dialog.Close(); - - await dialog.ShowDialog(owner); - } - - private async System.Threading.Tasks.Task ShowConfirmAsync(string message) - { - var owner = TopLevel.GetTopLevel(this) as Window; - if (owner == null) return false; - - var tcs = new TaskCompletionSource(); - - var cancel = new Button { Content = "Cancel", MinWidth = 90 }; - var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } }; - - var dialog = new Window - { - Title = "Confirm", - Width = 360, - SizeToContent = SizeToContent.Height, - CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - ShowInTaskbar = false, - Background = this.FindResource("SurfaceBrush") as IBrush, - Content = new StackPanel - { - Spacing = 16, - Margin = new Thickness(20), - Children = - { - new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - HorizontalAlignment = HorizontalAlignment.Right, - Children = { cancel, confirm } - } - } - } - }; - - cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); }; - confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); }; - dialog.Closed += (_, _) => tcs.TrySetResult(false); - - _ = dialog.ShowDialog(owner); - return await tcs.Task; - } } diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs index ffa18f42..69c7b2e5 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs @@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl await modal.ShowDialog(owner); // ShowDialog completes once the window is closed (CloseAction or OS close). }; - vm.ConfirmAsync = ShowConfirmAsync; vm.SelectionChanged += (_, _) => ScrollSelectedIntoView(); } }; @@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background); } - private async System.Threading.Tasks.Task ShowConfirmAsync(string message) - { - var owner = TopLevel.GetTopLevel(this) as Window; - if (owner is null) return false; - - var tcs = new TaskCompletionSource(); - var cancel = new Button { Content = "Cancel", MinWidth = 90 }; - var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } }; - - var dialog = new Window - { - Title = "Confirm", - Width = 380, - SizeToContent = SizeToContent.Height, - CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - ShowInTaskbar = false, - Background = this.FindResource("SurfaceBrush") as IBrush, - Content = new StackPanel - { - Margin = new Thickness(20), - Spacing = 16, - Children = - { - new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8, - Children = { cancel, confirm }, - }, - }, - }, - }; - - cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); }; - confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); }; - dialog.Closed += (_, _) => tcs.TrySetResult(false); - - _ = dialog.ShowDialog(owner); - return await tcs.Task; - } - // ── Custom ghost drag ──────────────────────────────────────────────────── // Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built // drag (pointer capture + a transparent topmost ghost window) is the only way to get a diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs index 5a59c6f3..dba2143e 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.VisualTree; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.Views.MissionControl; @@ -73,20 +74,21 @@ public partial class MissionControlView : UserControl UpdateQueueColumn(); } - // Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as - // TasksIslandView.ShowConfirmAsync (no shared control for this shape yet). + // Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed + // through WindowDialogService: it's owned by the main window, so showing it while Mission + // Control (a separate Window) is focused would put it behind Mission Control. private async Task ShowConfirmAsync(string message) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return false; var tcs = new TaskCompletionSource(); - var cancel = new Button { Content = "Cancel", MinWidth = 90 }; - var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } }; + var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 }; + var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } }; var dialog = new Window { - Title = "Confirm", + Title = Loc.T("dialog.confirmTitle"), Width = 380, SizeToContent = SizeToContent.Height, CanResize = false, diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index 86df0296..d9b1ca10 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -6,6 +6,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Layout; using Avalonia.Media; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Conflicts; @@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService public async Task ShowSettingsAsync(SettingsModalViewModel vm) { - vm.Worktrees.ConfirmAsync = ConfirmAsync; + vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message); var dlg = new SettingsModalView { DataContext = vm }; vm.RequestRepoImport = () => { @@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService { var dlg = new ListSettingsModalView { DataContext = vm }; vm.CloseAction = () => dlg.Close(); - vm.ConfirmAsync = ConfirmAsync; + vm.ConfirmAsync = message => ConfirmAsync(message); vm.ShowErrorAsync = ShowErrorAsync; await dlg.ShowDialog(_owner); } @@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService _diffViewerWindow = diffDlg; diffDlg.Show(_owner); }; - vm.ConfirmAction = ConfirmAsync; + vm.ConfirmAction = message => ConfirmAsync(message); if (Shell is { } shell) { vm.ResolveMergeVm = shell.ResolveMergeVm; @@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService _cheatSheets.Activate(); } - public Task ConfirmAsync(string message) + public Task ConfirmAsync(string message, string? confirmLabel = null) { var tcs = new TaskCompletionSource(); - var cancel = new Button { Content = "Cancel", MinWidth = 90 }; - var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } }; + var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 }; + var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } }; - var dialog = NoticeWindow("Confirm", 380, message, + var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message, new StackPanel { Orientation = Orientation.Horizontal, @@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService public Task ShowErrorAsync(string message) { - var ok = new Button { Content = "OK", MinWidth = 90 }; - var dialog = NoticeWindow("Error", 360, message, + var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 }; + var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message, new StackPanel { Orientation = Orientation.Horizontal, diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs index 7b292fc2..fba3d148 100644 --- a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs +++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs @@ -44,7 +44,7 @@ public class IslandsShellViewModelShutdownTests private sealed class FakeDialogService(bool confirmResult) : IDialogService { - public Task ConfirmAsync(string message) => Task.FromResult(confirmResult); + public Task ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(confirmResult); public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException(); public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException(); diff --git a/tests/ClaudeDo.Ui.Tests/TestDoubles.cs b/tests/ClaudeDo.Ui.Tests/TestDoubles.cs index 11fc917e..b4770683 100644 --- a/tests/ClaudeDo.Ui.Tests/TestDoubles.cs +++ b/tests/ClaudeDo.Ui.Tests/TestDoubles.cs @@ -1,6 +1,9 @@ using ClaudeDo.Data; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; +using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.ViewModels.Conflicts; +using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.Tests; @@ -31,3 +34,43 @@ public sealed class StubNotesApi : INotesApi /// is abstract — this is the concrete no-op subclass. public sealed class DefaultStub : StubWorkerClient { } + +/// +/// Records the message/label of the last Confirm/Error call and returns a configurable +/// Confirm result. Covers the ViewModels (DetailsIsland, TasksIsland, ...) that take their +/// dialog seam via a settable Dialogs property instead of a per-call constructor arg. +/// +public sealed class RecordingDialogService : IDialogService +{ + public bool ConfirmResult { get; set; } = true; + public string? LastConfirmMessage { get; private set; } + public string? LastConfirmLabel { get; private set; } + public string? LastErrorMessage { get; private set; } + + public Task ConfirmAsync(string message, string? confirmLabel = null) + { + LastConfirmMessage = message; + LastConfirmLabel = confirmLabel; + return Task.FromResult(ConfirmResult); + } + + public Task ShowErrorAsync(string message) + { + LastErrorMessage = message; + return Task.CompletedTask; + } + + public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException(); + public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel 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?> 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 void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); + public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException(); +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandDeleteTaskTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandDeleteTaskTests.cs index dbb79fe6..53db74c7 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandDeleteTaskTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandDeleteTaskTests.cs @@ -90,12 +90,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; }; var closed = false; vm.CloseDetail = () => closed = true; - string? reportedError = null; - vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; }; + var dialogs = new RecordingDialogService(); + vm.Dialogs = dialogs; await vm.DeleteTaskCommand.ExecuteAsync(null); - Assert.Equal(fkMessage, reportedError); + Assert.Equal(fkMessage, dialogs.LastErrorMessage); Assert.False(deleteFromListCalled); Assert.False(closed); } @@ -136,12 +136,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; }; var closed = false; vm.CloseDetail = () => closed = true; - string? reportedError = null; - vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; }; + var dialogs = new RecordingDialogService(); + vm.Dialogs = dialogs; await vm.DeleteTaskCommand.ExecuteAsync(null); - Assert.Equal("Connection is not active.", reportedError); + Assert.Equal("Connection is not active.", dialogs.LastErrorMessage); Assert.False(deleteFromListCalled); Assert.False(closed); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandErrorFeedbackTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandErrorFeedbackTests.cs index 940803d4..1aaa1858 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandErrorFeedbackTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandErrorFeedbackTests.cs @@ -164,7 +164,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed }); vm.Monitor.ApplyState(TaskStatus.Failed); - vm.ConfirmAsync = _ => Task.FromResult(true); + vm.Dialogs = new RecordingDialogService { ConfirmResult = true }; string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs index 28365936..075b89f0 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs @@ -153,12 +153,12 @@ public class DetailsIslandReviewActionsTests : IDisposable vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview }); vm.Monitor.ApplyState(TaskStatus.WaitingForReview); - string? reportedError = null; - vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; }; + var dialogs = new RecordingDialogService(); + vm.Dialogs = dialogs; await vm.ApproveReviewCommand.ExecuteAsync(null); - Assert.Equal(worker.ExceptionMessage, reportedError); + Assert.Equal(worker.ExceptionMessage, dialogs.LastErrorMessage); Assert.False(vm.ApproveOp.IsRunning); Assert.True(vm.ApproveReviewCommand.CanExecute(null)); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs index 752b685d..eb1e9a2d 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs @@ -62,7 +62,7 @@ public class ListsIslandCreateListCancelTests : IDisposable 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 ConfirmAsync(string message) => Task.FromResult(true); + public Task ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(true); public Task ShowErrorAsync(string message) => Task.CompletedTask; public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException(); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs index 34699d4a..3b758071 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs @@ -43,7 +43,7 @@ public class TasksIslandMoveToListTests : IDisposable public int ConfirmCalls; public string? LastMessage; - public Task ConfirmAsync(string message) + public Task ConfirmAsync(string message, string? confirmLabel = null) { ConfirmCalls++; LastMessage = message;