feat(ui): ssh workspaces settings tab

This commit is contained in:
mika kuns
2026-10-02 14:32:05 +02:00
parent 6c400dfb1a
commit 89ed885295
6 changed files with 245 additions and 3 deletions
@@ -0,0 +1,100 @@
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;
/// <summary>Settings tab for SSH workspaces offered as remote quick sessions in Mission Control.</summary>
public sealed partial class SshSettingsTabViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly Func<string> _readSshConfig;
/// <summary>Configured workspaces, edited in place.</summary>
public ObservableCollection<SshWorkspaceRowViewModel> Workspaces { get; } = new();
/// <summary>Host aliases from the user's ssh config, offered as suggestions.</summary>
public ObservableCollection<string> Hosts { get; } = new();
/// <summary>Creates the tab; <paramref name="readSshConfig"/> defaults to <c>~/.ssh/config</c>.</summary>
public SshSettingsTabViewModel(IWorkerClient worker, Func<string>? readSshConfig = null)
{
_worker = worker;
_readSshConfig = readSshConfig ?? (() => File.ReadAllText(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "config")));
}
/// <summary>Loads workspaces from the worker and host aliases from the ssh config.</summary>
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();
foreach (var w in await _worker.GetSshWorkspacesAsync())
Workspaces.Add(new SshWorkspaceRowViewModel(w.Id, Remove) { Name = w.Name, Host = w.Host, RemotePath = w.RemotePath });
}
[RelayCommand]
private void Add() => Workspaces.Add(new SshWorkspaceRowViewModel(Guid.NewGuid().ToString("n"), Remove));
private void Remove(SshWorkspaceRowViewModel row) => Workspaces.Remove(row);
/// <summary>Localized error for the first invalid row, or null.</summary>
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;
}
/// <summary>Persists all rows via the worker.</summary>
public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList());
/// <summary>Concrete <c>Host</c> aliases from ssh config text; wildcard patterns and <c>Match</c> blocks are skipped.</summary>
public static List<string> ParseHosts(string configText)
{
var hosts = new List<string>();
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;
}
}
/// <summary>One editable SSH workspace row.</summary>
public sealed partial class SshWorkspaceRowViewModel : ObservableObject
{
private readonly string _id;
private readonly Action<SshWorkspaceRowViewModel> _remove;
/// <summary>Display name shown in Mission Control.</summary>
[ObservableProperty] private string _name = "";
/// <summary>ssh config host alias.</summary>
[ObservableProperty] private string _host = "";
/// <summary>Absolute or <c>~</c>-relative directory on the server.</summary>
[ObservableProperty] private string _remotePath = "";
/// <summary>Creates a row with a stable id and a callback that removes it from the owner.</summary>
public SshWorkspaceRowViewModel(string id, Action<SshWorkspaceRowViewModel> remove)
{
_id = id;
_remove = remove;
}
[RelayCommand]
private void Remove() => _remove(this);
/// <summary>Snapshot as the persisted model, trimmed.</summary>
public SshWorkspace ToModel() => new(_id, Name.Trim(), Host.Trim(), RemotePath.Trim());
}