From 89ed8852952ddee134bea92ef930cb2f056574e6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 14:32:05 +0200 Subject: [PATCH] feat(ui): ssh workspaces settings tab --- src/ClaudeDo.Localization/locales/de.json | 16 ++- src/ClaudeDo.Localization/locales/en.json | 16 ++- .../Settings/SshSettingsTabViewModel.cs | 100 ++++++++++++++++++ .../Modals/SettingsModalViewModel.cs | 7 +- .../Views/Modals/SettingsModalView.axaml | 32 ++++++ .../SshSettingsTabViewModelTests.cs | 77 ++++++++++++++ 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index fd8a2ab1..2bf19ba6 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ac705af9..928398a3 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs new file mode 100644 index 00000000..9f8d927f --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs @@ -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; + +/// 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; + + /// 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(); + 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); + + /// 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. + public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList()); + + /// 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()); +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index add10d66..68bfa51c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -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 { @@ -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); } diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 0528c412..9e0de273 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -820,6 +820,38 @@ + + + + + + + + + + + + + + + + + + +