using System.Collections.ObjectModel; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; /// Settings tab for SSH workspaces offered as remote quick sessions in Mission Control. 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(); /// Host aliases from the user's ssh config, offered as suggestions. public ObservableCollection Hosts { get; } = new(); /// Creates the tab; defaults to ~/.ssh/config. public SshSettingsTabViewModel(IWorkerClient worker, Func? readSshConfig = null) { _worker = worker; _readSshConfig = readSshConfig ?? (() => File.ReadAllText(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "config"))); } /// Loads workspaces from the worker and host aliases from the ssh config. public async Task LoadAsync() { Hosts.Clear(); try { foreach (var h in ParseHosts(_readSshConfig())) Hosts.Add(h); } catch (Exception) { /* no ssh config = no suggestions, hosts can still be typed */ } Workspaces.Clear(); _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] private void Add() => Workspaces.Add(new SshWorkspaceRowViewModel(Guid.NewGuid().ToString("n"), Remove)); private void Remove(SshWorkspaceRowViewModel row) => Workspaces.Remove(row); /// Localized error for the first invalid row, or null. public string? Validate() { foreach (var row in Workspaces) if (SshWorkspace.Validate(row.ToModel()) is { } field) return Loc.T($"settings.ssh.invalid.{field}", row.Name); return null; } /// 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) { var hosts = new List(); foreach (var raw in configText.Split('\n')) { var parts = raw.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 2 || !parts[0].Equals("Host", StringComparison.OrdinalIgnoreCase)) continue; hosts.AddRange(parts.Skip(1).Where(p => p.IndexOfAny(['*', '?', '!']) < 0 && !hosts.Contains(p))); } return hosts; } } /// One editable SSH workspace row. public sealed partial class SshWorkspaceRowViewModel : ObservableObject { private readonly string _id; private readonly Action _remove; /// Display name shown in Mission Control. [ObservableProperty] private string _name = ""; /// ssh config host alias. [ObservableProperty] private string _host = ""; /// Absolute or ~-relative directory on the server. [ObservableProperty] private string _remotePath = ""; /// Creates a row with a stable id and a callback that removes it from the owner. public SshWorkspaceRowViewModel(string id, Action remove) { _id = id; _remove = remove; } [RelayCommand] private void Remove() => _remove(this); /// Snapshot as the persisted model, trimmed. public SshWorkspace ToModel() => new(_id, Name.Trim(), Host.Trim(), RemotePath.Trim()); }