chore(claude-do): merge fix(ui): Listen-Settings-Modal verschluckt Ladefehler nach d

ClaudeDo-Task: 9256fcfe-6cad-4350-a979-0c49cd5b9799
This commit is contained in:
mika kuns
2026-09-02 22:38:49 +02:00
5 changed files with 71 additions and 5 deletions
+1 -1
View File
@@ -772,7 +772,7 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen 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.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen 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.", "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.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "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}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "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}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —" }, "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+1 -1
View File
@@ -772,7 +772,7 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove 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).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset 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).", "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).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "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}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "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}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —" }, "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" },
"detailsIsland": { "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.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "detailsIsland": { "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.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
@@ -58,6 +58,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return; if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey); vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
@@ -408,6 +409,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null) if (Dialogs is not null && _services is not null)
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon); vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (!vm.Saved && !vm.Deleted) if (!vm.Saved && !vm.Deleted)
@@ -34,6 +34,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public Func<string, Task<bool>>? ConfirmAsync { get; set; } public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public Func<string, Task>? ShowErrorAsync { get; set; } public Func<string, Task>? ShowErrorAsync { get; set; }
// Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError.
public event Action<string>? ErrorReported;
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _workingDir = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
@@ -65,6 +68,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))] [NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
private bool _isLoading; private bool _isLoading;
// Set when the core load (name/agent/verify command) threw — keeps Save gated even after
// IsLoading resets to false in LoadAsync's finally, so a failed load can't clobber the
// stored config with blank fields.
private bool _loadFailed;
// True once GetTicketProjectsAsync has completed successfully for the current load — Save // True once GetTicketProjectsAsync has completed successfully for the current load — Save
// only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket // only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket
// system leaves the stored link untouched instead of wiping it to "none". // system leaves the stored link untouched instead of wiping it to "none".
@@ -128,8 +136,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string? icon = null, string? icon = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
ListConfigDto? cfg; ListConfigDto? cfg = null;
IsLoading = true; IsLoading = true;
_loadFailed = false;
try try
{ {
ListId = listId; ListId = listId;
@@ -147,8 +156,18 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false; PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
} }
catch (Exception ex)
{
// BeginLoad fires this without awaiting it — catching here (instead of letting it
// propagate) turns what would be an unobserved task exception, with the modal stuck
// open and every field blank, into a reported error and a locked Save button.
_loadFailed = true;
ErrorReported?.Invoke(Loc.T("vm.listSettings.loadFailed", ex.Message));
}
finally { IsLoading = false; } finally { IsLoading = false; }
if (_loadFailed) return;
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold // Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
// up the rest of the modal. The dropdown just stays empty until this finishes. // up the rest of the modal. The dropdown just stays empty until this finishes.
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId); _ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
@@ -179,15 +198,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
: TicketProjects[0]; : TicketProjects[0];
_ticketProjectsLoaded = true; _ticketProjectsLoaded = true;
} }
catch (Exception) catch (Exception ex)
{ {
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below) // Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
// skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored // skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored
// link survives instead of being wiped to "none". // link survives instead of being wiped to "none".
ErrorReported?.Invoke(Loc.T("vm.listSettings.ticketProjectsLoadFailed", ex.Message));
} }
} }
private bool CanSave() => !IsLoading; private bool CanSave() => !IsLoading && !_loadFailed;
private bool CanDelete() => !IsLoading; private bool CanDelete() => !IsLoading;
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand(CanExecute = nameof(CanSave))]
@@ -38,9 +38,12 @@ public class ListSettingsModalViewModelTests
public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory; public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory;
public UpdateListConfigDto? SavedListConfig; public UpdateListConfigDto? SavedListConfig;
public Exception? GetListConfigThrows;
public override Task<ListConfigDto?> GetListConfigAsync(string listId) public override Task<ListConfigDto?> GetListConfigAsync(string listId)
{ {
GetListConfigCalls++; GetListConfigCalls++;
if (GetListConfigThrows is not null) throw GetListConfigThrows;
return Task.FromResult(Cfg); return Task.FromResult(Cfg);
} }
@@ -125,4 +128,45 @@ public class ListSettingsModalViewModelTests
Assert.Equal(7, worker.SavedListConfig!.TicketProjectId); Assert.Equal(7, worker.SavedListConfig!.TicketProjectId);
} }
[Fact]
public async Task LoadAsync_CoreLoadThrows_BlocksSaveAndDoesNotOverwriteConfig()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null),
GetListConfigThrows = new InvalidOperationException("worker offline"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.False(vm.SaveCommand.CanExecute(null));
Assert.NotNull(reported);
Assert.Contains("worker offline", reported);
Assert.Null(worker.SavedListConfig);
}
[Fact]
public async Task LoadAsync_TicketProjectsThrow_ReportsErrorButRestOfLoadStillWorks()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, "make test", null, 7, true),
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system down"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed);
Assert.True(vm.SaveCommand.CanExecute(null));
Assert.NotNull(reported);
Assert.Contains("ticket system down", reported);
}
} }