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));
}
}