diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index d34c38f0..9b69b776 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -543,7 +543,7 @@ "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}" }, "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)." }, + "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}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, @@ -551,8 +551,8 @@ "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, - "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." }, - "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, + "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, + "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, "listSettings": { "untitled": "Unbenannt" }, "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8bc0e488..4c5f60e3 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -543,7 +543,7 @@ "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}" }, "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)." }, + "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}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, @@ -551,8 +551,8 @@ "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, - "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." }, - "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, + "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, + "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, "listSettings": { "untitled": "Untitled" }, "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } } diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 4ae3d840..b13d0753 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -329,8 +329,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("RefreshAgents"); } - public Task RestoreDefaultAgentsAsync() - => TryInvokeAsync("RestoreDefaultAgents"); + public async Task RestoreDefaultAgentsAsync() + => await _hub.InvokeAsync("RestoreDefaultAgents"); private async Task SeedActiveTasksAsync() { @@ -372,8 +372,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task> GetPrimeSchedulesAsync() => await TryInvokeAsync>("ListPrimeSchedules") ?? new List(); - public Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) - => TryInvokeAsync("UpsertPrimeSchedule", dto); + public async Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) + => await _hub.InvokeAsync("UpsertPrimeSchedule", dto); public async Task DeletePrimeScheduleAsync(Guid id) { @@ -400,8 +400,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task> GetDailyNotesAsync(DateOnly day) => await TryInvokeAsync>("GetDailyNotes", IsoDay(day)) ?? new List(); - public Task AddDailyNoteAsync(DateOnly day, string text) - => TryInvokeAsync("AddDailyNote", IsoDay(day), text); + public async Task AddDailyNoteAsync(DateOnly day, string text) + => await _hub.InvokeAsync("AddDailyNote", IsoDay(day), text); public async Task UpdateDailyNoteAsync(string id, string text) => await _hub.InvokeAsync("UpdateDailyNote", id, text); @@ -474,11 +474,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("CancelReview", taskId); } - public Task CleanupFinishedWorktreesAsync(string? listId = null) - => TryInvokeAsync("CleanupFinishedWorktrees", listId); + public async Task CleanupFinishedWorktreesAsync(string? listId = null) + => await _hub.InvokeAsync("CleanupFinishedWorktrees", listId); - public Task ResetAllWorktreesAsync() - => TryInvokeAsync("ResetAllWorktrees"); + public async Task ResetAllWorktreesAsync() + => await _hub.InvokeAsync("ResetAllWorktrees"); public async Task> GetWorktreesOverviewAsync(string? listId) => await TryInvokeAsync>("GetWorktreesOverview", listId) @@ -501,8 +501,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC } } - public Task ForceRemoveWorktreeAsync(string taskId) - => TryInvokeAsync("ForceRemoveWorktree", taskId); + public async Task ForceRemoveWorktreeAsync(string taskId) + => await _hub.InvokeAsync("ForceRemoveWorktree", taskId); public async Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct); @@ -543,8 +543,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task> GetPlanningAggregateAsync(string planningTaskId) => await TryInvokeAsync>("GetPlanningAggregate", planningTaskId) ?? []; - public Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) - => TryInvokeAsync("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); + public async Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) + => await _hub.InvokeAsync("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); public async Task ContinuePlanningMergeAsync(string planningTaskId) { diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 314ca732..93ea60ac 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -311,6 +311,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Prep = new PrepPanelViewModel(worker); Notes = new NotesEditorViewModel(_notesApi); + Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); }; Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs index b0c5d0a3..63db65fe 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs @@ -24,6 +24,9 @@ public sealed partial class NotesEditorViewModel : ViewModelBase public NotesEditorViewModel(INotesApi api) => _api = api; + // Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync). + public event Action? ErrorReported; + public ObservableCollection Bullets { get; } = new(); [ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today); @@ -55,9 +58,16 @@ public sealed partial class NotesEditorViewModel : ViewModelBase { var text = NewBulletText.Trim(); if (text.Length == 0) return; - var dto = await _api.AddAsync(CurrentDay, text); - if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); - NewBulletText = ""; + try + { + var dto = await _api.AddAsync(CurrentDay, text); + if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); + NewBulletText = ""; + } + catch (Exception ex) + { + ErrorReported?.Invoke(ex.Message); + } } [RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs index ca265973..7dda17d8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs @@ -197,6 +197,11 @@ public sealed partial class DiffViewerViewModel : ViewModelBase DisplayedDiff = ""; } } + catch (Exception ex) + { + DisplayedDiff = ""; + CombinedWarning = Loc.T("vm.planningDiff.buildFailed", ex.Message); + } finally { IsLoadingCombined = false; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs index b62376be..dc856723 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs @@ -46,6 +46,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase var r = await _worker.CleanupFinishedWorktreesAsync(); StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed); } + catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.cleanupFailed", ex.Message); } finally { IsBusy = false; } } @@ -63,6 +64,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks); else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected); } + catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); } finally { IsBusy = false; } } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index 9f7a4b87..d88a0895 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -174,6 +174,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed); await LoadAsync(); } + catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); } finally { IsBusy = false; } } @@ -241,7 +242,16 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; } if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; - var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); + ForceRemoveResultDto? result; + try + { + result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); + } + catch (Exception ex) + { + StatusMessage = Loc.T("vm.worktreesOverview.forceRemoveFailedDetailed", ex.Message); + return; + } if (result is null || !result.Removed) { StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed"); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs index bd9e092e..c38275d1 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs @@ -21,11 +21,14 @@ public class DiffViewerViewModelTests { public IReadOnlyList AggregateResult { get; set; } = Array.Empty(); public CombinedDiffResultDto? CombinedResult { get; set; } + public string? CombinedException { get; set; } public override Task> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult(AggregateResult); public override Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => - Task.FromResult(CombinedResult); + CombinedException is not null + ? throw new Exception(CombinedException) + : Task.FromResult(CombinedResult); } // ── Files mode: commit-range guards (ported from DiffModal) ── @@ -193,4 +196,26 @@ public class DiffViewerViewModelTests Assert.NotNull(vm.CombinedWarning); Assert.NotEmpty(vm.CombinedWarning!); } + + [Fact] + public async Task Planning_ToggleCombined_WhenWorkerThrows_ShowsExceptionMessage() + { + var fake = new FakePlanningWorker + { + AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, + CombinedException = "planning task not found", + }; + var vm = new DiffViewerViewModel(null!, fake); + vm.ConfigurePlanning("plan-1", "main"); + await vm.LoadAsync(); + + vm.IsCombinedMode = true; + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10); + + Assert.NotNull(vm.CombinedWarning); + Assert.Contains("planning task not found", vm.CombinedWarning); + Assert.Equal("", vm.DisplayedDiff); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/FilesSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/FilesSettingsTabViewModelTests.cs new file mode 100644 index 00000000..6761156d --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/FilesSettingsTabViewModelTests.cs @@ -0,0 +1,38 @@ +using System.IO; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals.Settings; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class FilesSettingsTabViewModelTests +{ + public FilesSettingsTabViewModelTests() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + + private sealed class ThrowingWorker : StubWorkerClient + { + public string ExceptionMessage { get; init; } = "permission denied copying agent files"; + public override Task RestoreDefaultAgentsAsync() => + throw new Exception(ExceptionMessage); + } + + [Fact] + public async Task RestoreDefaultAgents_WhenWorkerThrows_ShowsExceptionMessage_NotGenericOffline() + { + var worker = new ThrowingWorker(); + var vm = new FilesSettingsTabViewModel(worker); + + await vm.RestoreDefaultAgentsCommand.ExecuteAsync(null); + + Assert.Contains(worker.ExceptionMessage, vm.StatusMessage); + Assert.False(vm.IsBusy); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs index d56febcb..b2663b21 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs @@ -27,6 +27,33 @@ public class NotesEditorViewModelTests public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; } } + private sealed class ThrowingNotes : INotesApi + { + public string ExceptionMessage { get; init; } = "worker offline"; + public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + [Fact] + public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText() + { + var api = new ThrowingNotes(); + var vm = new NotesEditorViewModel(api); + await vm.LoadDayAsync(new DateOnly(2026, 6, 1)); + + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + + vm.NewBulletText = "Standup vorbereitet"; + await vm.AddBulletCommand.ExecuteAsync(null); + + Assert.Equal(api.ExceptionMessage, reportedError); + Assert.Empty(vm.Bullets); + Assert.Equal("Standup vorbereitet", vm.NewBulletText); + } + [Fact] public async Task AddBullet_PersistsAndAppears_ForCurrentDay() { diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs index b8c47f9a..a8c9fa6a 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs @@ -19,6 +19,14 @@ public class PrimeClaudeTabViewModelTests public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } } + private sealed class ThrowingApi : IPrimeScheduleApi + { + public string ExceptionMessage { get; init; } = "worker offline"; + public Task> ListAsync() => Task.FromResult(new List()); + public Task UpsertAsync(PrimeScheduleDto dto) => throw new Exception(ExceptionMessage); + public Task DeleteAsync(Guid id) => Task.CompletedTask; + } + private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) => new(id, days, time, true, null, null); @@ -93,4 +101,19 @@ public class PrimeClaudeTabViewModelTests vm.AddScheduleCommand.Execute(null); Assert.Null(vm.Validate()); } + + // SettingsModalViewModel.Save() is the only place that catches Prime.SaveAsync's + // failures (via a try/catch around the whole settings save) and surfaces them as + // "Save failed: {message}". For that to work, SaveAsync must propagate the worker's + // exception rather than swallow it. + [Fact] + public async Task Save_WhenApiThrows_PropagatesException() + { + var api = new ThrowingApi(); + var vm = new PrimeClaudeTabViewModel(api); + vm.AddScheduleCommand.Execute(null); + + var ex = await Assert.ThrowsAsync(() => vm.SaveAsync()); + Assert.Equal(api.ExceptionMessage, ex.Message); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewModalErrorTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewModalErrorTests.cs new file mode 100644 index 00000000..83d0b017 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewModalErrorTests.cs @@ -0,0 +1,65 @@ +using System.IO; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class WorktreesOverviewModalErrorTests +{ + public WorktreesOverviewModalErrorTests() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + + private sealed class ThrowingWorker : StubWorkerClient + { + public string ExceptionMessage { get; init; } = "worktree is locked by another process"; + public override Task CleanupFinishedWorktreesAsync(string? listId = null) => + throw new Exception(ExceptionMessage); + public override Task ForceRemoveWorktreeAsync(string taskId) => + throw new Exception(ExceptionMessage); + } + + private static WorktreesOverviewModalViewModel NewVm(ThrowingWorker worker) => + new(worker, () => null!, new MergeCoordinator()); + + [Fact] + public async Task CleanupFinished_WhenWorkerThrows_ShowsExceptionMessage() + { + var worker = new ThrowingWorker(); + var vm = NewVm(worker); + + await vm.CleanupFinishedCommand.ExecuteAsync(null); + + Assert.NotNull(vm.StatusMessage); + Assert.Contains(worker.ExceptionMessage, vm.StatusMessage); + Assert.False(vm.IsBusy); + } + + [Fact] + public async Task ForceRemove_WhenWorkerThrows_ShowsExceptionMessage_AndKeepsRow() + { + var worker = new ThrowingWorker(); + var vm = NewVm(worker); + var row = new WorktreeOverviewRowViewModel + { + TaskId = "task-1", + TaskTitle = "Task 1", + TaskStatus = ClaudeDo.Data.Models.TaskStatus.Idle, + State = ClaudeDo.Data.Models.WorktreeState.Active, + }; + vm.AddRowForTest(row); + + await vm.ForceRemoveCommand.ExecuteAsync(row); + + Assert.NotNull(vm.StatusMessage); + Assert.Contains(worker.ExceptionMessage, vm.StatusMessage); + Assert.Contains(row, vm.Rows); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesSettingsTabViewModelTests.cs new file mode 100644 index 00000000..da725325 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesSettingsTabViewModelTests.cs @@ -0,0 +1,53 @@ +using System.IO; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals.Settings; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class WorktreesSettingsTabViewModelTests +{ + public WorktreesSettingsTabViewModelTests() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + + private sealed class ThrowingWorker : StubWorkerClient + { + public string ExceptionMessage { get; init; } = "disk full"; + public override Task CleanupFinishedWorktreesAsync(string? listId = null) => + throw new Exception(ExceptionMessage); + public override Task ResetAllWorktreesAsync() => + throw new Exception(ExceptionMessage); + } + + [Fact] + public async Task CleanupWorktrees_WhenWorkerThrows_ShowsExceptionMessage() + { + var worker = new ThrowingWorker(); + var vm = new WorktreesSettingsTabViewModel(worker); + + await vm.CleanupWorktreesCommand.ExecuteAsync(null); + + Assert.Contains(worker.ExceptionMessage, vm.StatusMessage); + Assert.False(vm.IsBusy); + } + + [Fact] + public async Task ConfirmResetAll_WhenWorkerThrows_ShowsExceptionMessage() + { + var worker = new ThrowingWorker(); + var vm = new WorktreesSettingsTabViewModel(worker); + + await vm.ConfirmResetAllCommand.ExecuteAsync(null); + + Assert.Contains(worker.ExceptionMessage, vm.StatusMessage); + Assert.False(vm.IsBusy); + Assert.False(vm.ShowResetConfirm); + } +}