From 8c5db95c012e89f1c45fb09244a0ddfca195ee22 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 14:33:57 +0200 Subject: [PATCH] fix(ui): ssh settings tab survives an offline worker without wiping workspaces --- .../Settings/SshSettingsTabViewModel.cs | 16 +++++++++++++--- .../SshSettingsTabViewModelTests.cs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs index 9f8d927f..c953dd9c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs @@ -13,6 +13,9 @@ public sealed partial class SshSettingsTabViewModel : ViewModelBase private readonly IWorkerClient _worker; private readonly Func _readSshConfig; + /// True once the stored workspaces were read; gates . + private bool _loaded; + /// Configured workspaces, edited in place. public ObservableCollection Workspaces { get; } = new(); @@ -35,8 +38,13 @@ public sealed partial class SshSettingsTabViewModel : ViewModelBase catch (Exception) { /* no ssh config = no suggestions, hosts can still be typed */ } Workspaces.Clear(); - foreach (var w in await _worker.GetSshWorkspacesAsync()) + _loaded = false; + List stored; + try { stored = await _worker.GetSshWorkspacesAsync(); } + catch (Exception) { return; } // worker offline: the modal already reports it, and Save must not wipe what it couldn't read + foreach (var w in stored) Workspaces.Add(new SshWorkspaceRowViewModel(w.Id, Remove) { Name = w.Name, Host = w.Host, RemotePath = w.RemotePath }); + _loaded = true; } [RelayCommand] @@ -53,8 +61,10 @@ public sealed partial class SshSettingsTabViewModel : ViewModelBase return null; } - /// Persists all rows via the worker. - public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList()); + /// Persists all rows via the worker; a no-op when the load failed. + public Task SaveAsync() => _loaded + ? _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList()) + : Task.CompletedTask; /// Concrete Host aliases from ssh config text; wildcard patterns and Match blocks are skipped. public static List ParseHosts(string configText) diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs index 68b05a26..3d749509 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs @@ -74,4 +74,23 @@ public sealed class SshSettingsTabViewModelTests await vm.LoadAsync(); Assert.Empty(vm.Hosts); } + + private sealed class OfflineWorker : StubWorkerClient + { + public bool SetCalled; + public override Task> GetSshWorkspacesAsync() => throw new InvalidOperationException("offline"); + public override Task SetSshWorkspacesAsync(List workspaces) { SetCalled = true; return Task.CompletedTask; } + } + + [Fact] + public async Task WorkerOffline_LoadDoesNotThrow_AndSaveDoesNotWipe() + { + var worker = new OfflineWorker(); + var vm = new SshSettingsTabViewModel(worker, () => ""); + + await vm.LoadAsync(); + await vm.SaveAsync(); + + Assert.False(worker.SetCalled); + } }