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
+15 -1
View File
@@ -16,7 +16,8 @@
"categoryFiles": "Dateien & Prompts", "categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Berichte", "categoryReports": "Berichte",
"categoryTickets": "Externe Dienste" "categoryTickets": "Externe Dienste",
"categorySsh": "SSH-Workspaces"
}, },
"repoHint": { "repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.", "text": "Wirkt erst mit verknüpftem Repo.",
@@ -162,6 +163,19 @@
"fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.", "fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.",
"fallbackNone": "(keine)" "fallbackNone": "(keine)"
}, },
"ssh": {
"hint": "Server, auf denen Mission Control eine interaktive Claude-Session starten kann. Host ist ein Alias aus ~/.ssh/config (User, Key und Port kommen von dort); Claude muss auf dem Server installiert und eingeloggt sein.",
"name": "Name",
"host": "Host-Alias",
"remotePath": "Pfad auf dem Server (/… oder ~/…)",
"add": "Workspace hinzufügen",
"remove": "Entfernen",
"invalid": {
"name": "SSH-Workspace: Name fehlt.",
"host": "SSH-Workspace „{0}“: Host muss ein ssh-Alias sein (Buchstaben, Ziffern, . _ @ -, nicht mit - beginnend).",
"path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten."
}
},
"inherit": { "inherit": {
"inheritedFromList": "geerbt · Liste", "inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global", "inheritedFromGlobal": "geerbt · Global",
+15 -1
View File
@@ -16,7 +16,8 @@
"categoryFiles": "Files & Prompts", "categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Reports", "categoryReports": "Reports",
"categoryTickets": "External services" "categoryTickets": "External services",
"categorySsh": "SSH workspaces"
}, },
"repoHint": { "repoHint": {
"text": "Only takes effect once a repo is linked.", "text": "Only takes effect once a repo is linked.",
@@ -162,6 +163,19 @@
"fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.", "fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.",
"fallbackNone": "(none)" "fallbackNone": "(none)"
}, },
"ssh": {
"hint": "Servers you can start an interactive Claude session on from Mission Control. Host is an alias from ~/.ssh/config (user, key and port come from there); Claude must be installed and logged in on the server.",
"name": "Name",
"host": "Host alias",
"remotePath": "Remote path (/… or ~/…)",
"add": "Add workspace",
"remove": "Remove",
"invalid": {
"name": "SSH workspace: name is required.",
"host": "SSH workspace \"{0}\": host must be an ssh alias (letters, digits, . _ @ -, not starting with -).",
"path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '."
}
},
"inherit": { "inherit": {
"inheritedFromList": "inherited · List", "inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global", "inheritedFromGlobal": "inherited · Global",
@@ -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());
}
@@ -28,6 +28,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public OnlineInboxSettingsViewModel OnlineInbox { get; } public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; } public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; } public TicketSettingsTabViewModel Tickets { get; }
public SshSettingsTabViewModel Ssh { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false; public bool ShowOnlineInbox => false;
@@ -109,6 +110,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService); OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker, dbFactory); Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
Ssh = new SshSettingsTabViewModel(worker);
_categories = new List<SettingsCategory> _categories = new List<SettingsCategory>
{ {
@@ -121,6 +123,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true), new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false), new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false), new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false),
new("Ssh", new LocalizedString(localizer, "settings.sidebar.categorySsh"), "Erweitert", true, false),
}; };
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList(); BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList(); ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
@@ -177,6 +180,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await OnlineInbox.LoadAsync(); await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(dto?.SessionSkills); await SessionSkills.LoadAsync(dto?.SessionSkills);
await Tickets.LoadAsync(); await Tickets.LoadAsync();
await Ssh.LoadAsync();
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling); General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
@@ -188,7 +192,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async Task Save() private async Task Save()
{ {
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate(); var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate();
if (err is not null) { ValidationError = err; return; } if (err is not null) { ValidationError = err; return; }
ValidationError = ""; ValidationError = "";
@@ -231,6 +235,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
await Tickets.SaveAsync(); await Tickets.SaveAsync();
await Ssh.SaveAsync();
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); } catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
@@ -820,6 +820,38 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.sidebar.categorySsh}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock Classes="meta" Text="{loc:Tr settings.ssh.hint}" TextWrapping="Wrap" Opacity="0.6"/>
<ItemsControl ItemsSource="{Binding Ssh.Workspaces}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:SshWorkspaceRowViewModel">
<Grid ColumnDefinitions="160,180,*,Auto" ColumnSpacing="8" Margin="0,0,0,6">
<TextBox Grid.Column="0" Text="{Binding Name, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.ssh.name}"/>
<AutoCompleteBox Grid.Column="1" Text="{Binding Host, Mode=TwoWay}"
ItemsSource="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Ssh.Hosts}"
MinimumPrefixLength="0" FilterMode="StartsWith"
Watermark="{loc:Tr settings.ssh.host}"/>
<TextBox Grid.Column="2" Text="{Binding RemotePath, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.ssh.remotePath}"/>
<Button Grid.Column="3" Classes="icon-btn" Command="{Binding RemoveCommand}"
ToolTip.Tip="{loc:Tr settings.ssh.remove}">
<PathIcon Width="14" Height="14" Data="{Binding Source='mdi:TrashCanOutline', Converter={StaticResource IconKey}}"/>
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="btn" HorizontalAlignment="Left" Command="{Binding Ssh.AddCommand}"
Content="{loc:Tr settings.ssh.add}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -0,0 +1,77 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public sealed class SshSettingsTabViewModelTests
{
private sealed class Worker : StubWorkerClient
{
public List<SshWorkspace> Stored = new();
public override Task<List<SshWorkspace>> GetSshWorkspacesAsync() => Task.FromResult(Stored.ToList());
public override Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces) { Stored = workspaces; return Task.CompletedTask; }
}
[Fact]
public void ParseHosts_SkipsWildcardsAndComments_SplitsMultiAlias()
{
var text = """
# comment
Host *
User root
Host vps vps-alt
HostName 1.2.3.4
host gitea
Host *.internal
Match host foo
""";
Assert.Equal(["vps", "vps-alt", "gitea"], SshSettingsTabViewModel.ParseHosts(text));
}
[Fact]
public async Task LoadAddSave_RoundTripsRows()
{
var worker = new Worker { Stored = [new SshWorkspace("w1", "VPS", "vps", "/srv/app")] };
var vm = new SshSettingsTabViewModel(worker, () => "Host vps");
await vm.LoadAsync();
Assert.Single(vm.Workspaces);
Assert.Equal(["vps"], vm.Hosts);
vm.AddCommand.Execute(null);
vm.Workspaces[1].Name = "Box";
vm.Workspaces[1].Host = "box";
vm.Workspaces[1].RemotePath = "~/code";
Assert.Null(vm.Validate());
await vm.SaveAsync();
Assert.Equal(2, worker.Stored.Count);
Assert.Equal("w1", worker.Stored[0].Id); // existing id preserved
Assert.False(string.IsNullOrEmpty(worker.Stored[1].Id)); // new row got an id
}
[Fact]
public async Task Validate_ReportsInvalidRow_AndRemoveDropsIt()
{
var vm = new SshSettingsTabViewModel(new Worker(), () => "");
await vm.LoadAsync();
vm.AddCommand.Execute(null);
vm.Workspaces[0].Name = "Bad";
vm.Workspaces[0].Host = "-oProxyCommand=calc";
vm.Workspaces[0].RemotePath = "/x";
Assert.NotNull(vm.Validate());
vm.Workspaces[0].RemoveCommand.Execute(null);
Assert.Empty(vm.Workspaces);
Assert.Null(vm.Validate());
}
[Fact]
public async Task MissingSshConfig_YieldsNoHosts()
{
var vm = new SshSettingsTabViewModel(new Worker(), () => throw new FileNotFoundException());
await vm.LoadAsync();
Assert.Empty(vm.Hosts);
}
}