using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Domain; using DodoSSH.Client.Import; namespace DodoSSH.Client.Shell.ViewModels; /// One host an ssh_config offered, as a row somebody decides about. /// /// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list /// and pressed the button, which is what makes reading a file out of the user's home directory an offer /// rather than an action. /// internal sealed partial class ImportRowViewModel : ObservableObject { private readonly ImportedHost host; internal ImportRowViewModel(ImportedHost host, bool alreadyPresent) { this.host = host; AlreadyPresent = alreadyPresent; // A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark // for one machine is a thing people genuinely want — but it should take a click rather than be the // default. IsSelected = !alreadyPresent; } internal ImportedHost Host => host; internal string Alias => host.Alias; internal string Address => host.Address; /// Whether a host with this address is already in the keychain. internal bool AlreadyPresent { get; } internal string Badge => AlreadyPresent ? "already here" : string.Empty; internal bool HasBadge => AlreadyPresent; /// How this would authenticate, in the terms the preview can honestly offer. /// /// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host /// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a /// connection that does not work. /// internal string Authentication => host.IdentityFiles.Count switch { 0 => "password", 1 => $"a key on disk · {host.IdentityFiles[0]}", var count => $"{count} keys on disk · {host.IdentityFiles[0]}", }; internal bool HasWarnings => host.Warnings.Count > 0; internal string Warnings => string.Join(" ", host.Warnings); [ObservableProperty] private bool isSelected; } /// /// Reading ~/.ssh/config and offering what it found. /// /// /// /// Two steps, and the first one writes nothing. Scanning reads the file and shows what it means; /// importing is a separate press. That split is the feature: an ssh_config is a file this /// application did not write and may contain forty entries for machines that no longer exist, so the /// interesting question is not "can it be parsed" but "which of these did you actually want". /// /// /// Nothing reads a private key. An IdentityFile becomes a directive and a note recording the /// path. Pulling someone's ~/.ssh/id_ed25519 into a keychain as a side effect of importing a config /// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen /// for making one deliberately, and pasting an existing one is a deliberate act too. /// /// internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject { internal ObservableCollection Rows { get; } = []; /// What was skipped or flattened, at document level. internal ObservableCollection Warnings { get; } = []; /// The file this would read, shown so nobody has to guess which one it means. internal string ConfigPath => locator.ConfigPath; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private bool hasScanned; [ObservableProperty] private bool isBusy; internal bool HasRows => Rows.Count > 0; internal bool HasWarnings => Warnings.Count > 0; internal int SelectedCount => Rows.Count(row => row.IsSelected); internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS"; /// Reads the file and shows what it found. Writes nothing. [RelayCommand] private async Task ScanAsync(CancellationToken cancellationToken) { Rows.Clear(); Warnings.Clear(); HasScanned = false; if (!locator.Exists) { Status = $"There is no {locator.ConfigPath} on this machine."; RaiseListState(); return; } IsBusy = true; try { var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true); foreach (var host in import.Hosts) { Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host))); } foreach (var warning in import.Warnings) { Warnings.Add(warning); } HasScanned = true; Status = Rows.Count == 0 ? "Nothing in that file could be imported as a host." : $"Found {Rows.Count} host(s). Nothing is stored until you press the button below."; } catch (IOException failure) { Status = $"Could not read {locator.ConfigPath}: {failure.Message}"; } catch (UnauthorizedAccessException failure) { Status = $"Could not read {locator.ConfigPath}: {failure.Message}"; } finally { IsBusy = false; RaiseListState(); } } /// Stores the ticked hosts. [RelayCommand] private async Task ImportAsync(CancellationToken cancellationToken) { var chosen = Rows.Where(row => row.IsSelected).ToList(); if (chosen.Count == 0) { Status = "Nothing is ticked."; return; } IsBusy = true; try { var imported = await vault .ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken) .ConfigureAwait(true); // Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes // pressing the button twice harmless and visible rather than harmless and confusing. foreach (var row in Rows.ToList()) { Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host)); } Status = $"Imported {imported} host(s). They are on the Hosts screen."; } finally { IsBusy = false; RaiseListState(); } } /// Ticks or unticks everything at once. [RelayCommand] private void ToggleAll() { var target = SelectedCount < Rows.Count; foreach (var row in Rows) { row.IsSelected = target; } RaiseListState(); } internal void NoteSelectionChanged() => RaiseListState(); /// /// Matched on where a host points rather than on what it is called. Two entries with different aliases /// for one machine are the ordinary shape of an ssh_config, and matching on the name would offer /// to import a duplicate of something already stored under another name. /// /// /// Whether this block describes a machine the vault already has. /// /// /// /// Compared against the resolved host, not the stored one. A stored host that takes its port and /// username from its group is the same machine as an imported block naming them outright — and comparing /// the stored fields would leave it unmatched, so the import screen would offer to add a duplicate of /// every host that inherits anything. Duplicates offered by a screen whose whole job is to say what is /// new are worse than a missed match: they get accepted. /// /// /// An imported block with no Port still pins 22 rather than inheriting, which /// SshConfigResolver already does and this deliberately leaves alone. Nothing imported is filed /// into a group — there is no group picker here — so an inherited port would resolve to 22 anyway, and /// the two would differ only in which of them a later edit to some group could change underneath the /// user. An absent Port in an ssh_config means 22; storing that is the faithful reading. /// /// private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing => string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase) && existing.Resolved.Port.Value == host.Port && string.Equals( existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase)); private void RaiseListState() { OnPropertyChanged(nameof(HasRows)); OnPropertyChanged(nameof(HasWarnings)); OnPropertyChanged(nameof(SelectedCount)); OnPropertyChanged(nameof(ImportLabel)); } }