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());
}