feat(ui): ssh workspaces settings tab
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"categoryFiles": "Dateien & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Berichte",
|
||||
"categoryTickets": "Externe Dienste"
|
||||
"categoryTickets": "Externe Dienste",
|
||||
"categorySsh": "SSH-Workspaces"
|
||||
},
|
||||
"repoHint": {
|
||||
"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.",
|
||||
"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": {
|
||||
"inheritedFromList": "geerbt · Liste",
|
||||
"inheritedFromGlobal": "geerbt · Global",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"categoryFiles": "Files & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Reports",
|
||||
"categoryTickets": "External services"
|
||||
"categoryTickets": "External services",
|
||||
"categorySsh": "SSH workspaces"
|
||||
},
|
||||
"repoHint": {
|
||||
"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.",
|
||||
"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": {
|
||||
"inheritedFromList": "inherited · List",
|
||||
"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 SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||
public TicketSettingsTabViewModel Tickets { get; }
|
||||
public SshSettingsTabViewModel Ssh { get; }
|
||||
|
||||
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
|
||||
public bool ShowOnlineInbox => false;
|
||||
@@ -109,6 +110,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||
Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
|
||||
Ssh = new SshSettingsTabViewModel(worker);
|
||||
|
||||
_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("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, 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();
|
||||
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
|
||||
@@ -177,6 +180,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
await OnlineInbox.LoadAsync();
|
||||
await SessionSkills.LoadAsync(dto?.SessionSkills);
|
||||
await Tickets.LoadAsync();
|
||||
await Ssh.LoadAsync();
|
||||
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
@@ -188,7 +192,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
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; }
|
||||
ValidationError = "";
|
||||
|
||||
@@ -231,6 +235,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
await Tickets.SaveAsync();
|
||||
await Ssh.SaveAsync();
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
|
||||
|
||||
@@ -820,6 +820,38 @@
|
||||
</ScrollViewer>
|
||||
</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>
|
||||
</Grid>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user