using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Shell.ViewModels;
/// Which of the wizard's three steps is on screen.
///
/// A wizard rather than one screen, unlike , and the reason is what each step
/// costs. Reading an ssh_config is free and local, so that screen scans the moment it opens and shows
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
/// and it then makes a request over the internet. Those are two different moments, and a screen that mixed
/// the token box in with the list would ask for the credential again every time somebody came back to look
/// at the list.
///
internal enum HetznerImportStep
{
/// Paste a token. Nothing has been sent yet.
Token = 0,
/// The project's servers, and what to do with them. Nothing has been stored yet.
Choose = 1,
/// What was stored.
Done = 2,
}
///
/// One Hetzner server, as a row somebody decides about.
///
///
/// The address is not a property of the server but of the server and the choice above the list, so
/// it is pushed down by rather than computed here — the same shape
/// has, and for the same reason: a control that decides one
/// thing for forty rows belongs above the forty rows.
///
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
{
private HetznerAddress? resolved;
internal HetznerServer Server => server;
internal string Name => server.Name;
/// Whether it is running, off, or somewhere in between — a fact of the moment, never stored.
internal string Status => server.Status;
/// The plan and where it runs, as one cell.
internal string Kind => (server.ServerType, server.Location) switch
{
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
({ Length: > 0 } plan, _) => plan,
(_, { Length: > 0 } place) => place,
_ => "—",
};
/// What this row would dial, or an em dash where the address choice leaves it nothing.
internal string Address => resolved?.Value ?? "—";
/// Whether the address choice above the list can reach this server at all.
///
/// False is the private-network case: a server on no private network has no private address, and this
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
/// an import that was asked to stay off the internet from putting a public address in the keychain.
///
internal bool CanImport => resolved is not null;
/// Whether the keychain already has a host pointing where this one would.
internal bool AlreadyPresent { get; private set; }
/// What had to be assumed to reach that address, if anything.
internal string Warning => resolved?.Warning ?? string.Empty;
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
///
/// The row's own chip: the single most actionable thing true about it.
///
///
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
/// here", which is only about whether it is worth ticking. Deliberately parallel to
/// — the same three states plus the one that screen cannot
/// have, because a resolved ssh_config entry always has a hostname.
///
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
{
(false, _, _) => "no address of that kind",
(_, true, _) => Warning,
(_, _, true) => "already here",
_ => "new host",
};
internal bool IsMeaningWarned => !CanImport || HasWarning;
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
/// The address, resolved, or null where there is none of the chosen kind.
internal HetznerAddress? Resolved => resolved;
[ObservableProperty]
private bool isSelected;
///
/// Re-resolves this row against the address choice above the list.
///
/// Which address the import is dialling.
/// Whether the keychain already holds a host at a given address.
///
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
/// question "which of these do you want" is being put again — and false when only the address choice
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
/// A row that has lost its address is unticked either way; there is nothing to import.
///
internal void Refresh(
HetznerAddressKind kind,
Func isAlreadyPresent,
bool resetSelection)
{
resolved = server.Resolve(kind);
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
if (resetSelection)
{
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
// want — but it should take a click rather than be the default.
IsSelected = CanImport && !AlreadyPresent;
}
else if (!CanImport)
{
IsSelected = false;
}
OnPropertyChanged(nameof(Address));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(AlreadyPresent));
OnPropertyChanged(nameof(Warning));
OnPropertyChanged(nameof(HasWarning));
OnPropertyChanged(nameof(Meaning));
OnPropertyChanged(nameof(IsMeaningWarned));
OnPropertyChanged(nameof(IsMeaningExisting));
OnPropertyChanged(nameof(IsMeaningNew));
}
}
///
/// One entry in the import's key picker: a key already in the keychain, or none at all.
///
/// The key, or null for "no key".
/// The vault that key lives in, or null for the "no key" entry.
/// What the picker shows.
///
///
/// ◆ An id rather than the key, because nothing here reads key material. The import writes
/// HostSecret.SshKeyId and stops; the private half stays where it already was, in the one vault
/// entry every host that uses it points at. That is the same argument VaultViewModel.ImportHostsAsync
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
/// is no file at all, only a key the keychain has had all along.
///
///
/// A sentinel entry for "no key" rather than a null selection, for the reason GroupChoice.None gives:
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
/// same thing — and on this screen the second is a promise about how forty machines will behave.
///
///
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
{
/// The "bind nothing" entry, always first and selected until somebody chooses otherwise.
///
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
/// typed. Chosen, it is one answer to one question the person running the import already knows.
///
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
}
///
/// Importing the servers in a Hetzner Cloud project.
///
///
///
/// Three steps, and the first two write nothing. Step one takes a token and sends one request; step
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
/// the same argument makes — a project with forty servers is forty questions,
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
/// import has and that one does not: the credential. It has to be fetched from another company's console
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
///
///
/// The token is never stored. It lives in while the wizard is open and is cleared
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
/// again. That is the same reasoning gives for not remembering its
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
/// stored credential capable of listing somebody's whole estate.
///
///
/// Nothing imported here authenticates. Hetzner has never held the private half of an SSH key, so
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
/// time — see .
///
///
internal sealed partial class HetznerImportViewModel(
VaultViewModel vault,
HetznerCloudClient hetzner,
Action? onCancel = null) : ObservableObject
{
///
/// The user a stock Hetzner Linux image logs in as.
///
///
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
/// estate at once, which is why the box is above the list rather than on each row.
///
internal const string DefaultUsername = "root";
internal ObservableCollection Rows { get; } = [];
/// What the project could not be made to say, at project level.
internal ObservableCollection Warnings { get; } = [];
[ObservableProperty]
private HetznerImportStep step = HetznerImportStep.Token;
///
/// The Cloud API token, held only for as long as the wizard needs it.
///
///
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
/// that outlives the process. clears it as soon as the read is finished.
///
[ObservableProperty]
private string token = string.Empty;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isBusy;
/// The login user every imported host gets.
[ObservableProperty]
private string username = DefaultUsername;
///
/// The SSH port, as typed.
///
///
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
/// , which is also what reports on.
///
[ObservableProperty]
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
private const int HetznerPort = 22;
[ObservableProperty]
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
internal bool IsTokenStep => Step is HetznerImportStep.Token;
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
internal bool IsDoneStep => Step is HetznerImportStep.Done;
/// "Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
/// What this step is for, in the header, beside .
internal string StepTitle => Step switch
{
HetznerImportStep.Token => "Connect to a project",
HetznerImportStep.Choose => "Choose what to import",
_ => "Imported",
};
///
/// Which of the three address chips above the list is lit.
///
///
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
/// control is a chip that runs — see HetznerImportScreen.axaml
/// for why chips rather than radio buttons, which this application has no style for.
///
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
///
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
///
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
/// What the chosen address means for the list, said once above it rather than per row.
internal string AddressNote => AddressKind switch
{
HetznerAddressKind.PublicIPv4 =>
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
HetznerAddressKind.PublicIPv6 =>
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
+ "than an address, so this is the first address in it — where Hetzner's own images put "
+ "the server, and not something the API states.",
_ =>
"The address on the server's own private network, which only reaches it from inside that "
+ "network. Servers on no private network are not importable under this choice, and this "
+ "will never quietly fall back to a public address.",
};
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
/// The port every imported host gets, or null where what is typed is not one.
internal int? Port =>
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
&& port is >= 1 and <= 65535
? port
: null;
internal bool IsPortValid => Port is not null;
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
/// How many rows the address choice can actually reach.
internal int ImportableCount => Rows.Count(row => row.CanImport);
/// Whether every importable row is ticked — what the header's tick-all box shows.
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
/// Whether IMPORT would do anything.
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
///
/// Where the imported hosts go — see .
///
///
///
/// ◆ A picker rather than the active vault, which is what the ssh_config importer still uses.
/// The two are not the same question. That screen reads ~/.ssh/config — this machine's own
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
/// items moved one at a time afterwards.
///
///
/// Its own selection rather than 's, on the reasoning
/// VaultViewModel.EditorVaultChoices gives for the host editor's: that one is a standing
/// preference about where new items go, and this is a field of the import in front of you. Filled from
/// the same list, so it offers what every other "file this into" control offers — vaults this session
/// can both read and write.
///
///
internal ObservableCollection VaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedVault;
/// Whether there is more than one vault to choose between.
///
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
/// offering one option is a question with no answer.
///
internal bool HasVaultChoice => VaultChoices.Count > 1;
/// The vault the import writes to, or null for the active one.
internal Guid? TargetVaultId => SelectedVault?.VaultId;
/// What the summary line calls the vault the hosts are going into.
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
///
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
///
///
///
/// ◆ The one thing this import could never do, done by pointing rather than by bringing. Hetzner
/// has never held a private half, so no key can arrive with these hosts — but a key that is
/// already in the keychain is almost always the key that opens them, because it is the one whose public
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
/// estate; without it, it was forty visits to forty host editors.
///
///
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
/// resolve its key, and that is said in rather than prevented here.
///
///
internal ObservableCollection KeyChoices { get; } = [];
[ObservableProperty]
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
/// Whether the keychain has a key to offer at all.
///
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
/// would do something.
///
internal bool HasKeyChoice => KeyChoices.Count > 1;
/// The key every imported host is bound to, where one was chosen.
internal Guid? BoundKeyId => SelectedKey?.KeyId;
/// How many ticked, out of how many, and where they land.
internal string SelectionSummary
{
get
{
var noun = Rows.Count == 1 ? "server" : "servers";
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
}
}
///
/// The sentence that says how an imported host will authenticate.
///
///
///
/// Said before the button rather than after, on exactly the reasoning ImportScreen.axaml gives
/// for its own version: whether the credential comes with the host is the difference between a bookmark
/// that connects and one that asks for a password, and somebody who is not told will conclude the
/// import was broken.
///
///
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
///
///
internal string AuthenticationNote => SelectedKey?.KeyId is null
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
+ "one — from the host's own editor, or in bulk from the Hosts screen."
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
+ $"itself is not copied and not read.{StrandedKeyNote}";
///
/// What is said when the chosen key sits in a different vault from the hosts.
///
///
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
/// because it is being done to a whole estate at once: a binding resolves across every vault
/// this account can read, so the import connects perfectly from this machine and not at all
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
/// hosts in three vaults is what keys are for.
///
private string StrandedKeyNote =>
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
+ "see hosts they cannot authenticate."
: string.Empty;
/// What the token step says about the token it is asking for.
///
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
/// say so would look like it had lost most of the estate.
///
internal static string TokenNote =>
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
+ "covered here.";
/// Reads the project. Writes nothing, and forgets the token on the way out.
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
{
ClearRows();
RefreshChoices();
IsBusy = true;
try
{
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
if (Rows.Count == 0)
{
// Deliberately not an error, and deliberately not a step forward: an empty project is a
// real answer to a correct token, and a list screen with nothing on it says less than this.
Status = "That project has no servers in it.";
return;
}
Status = Rows.Count == 1
? "Found 1 server. Nothing is stored until you press the button below."
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
Step = HetznerImportStep.Choose;
}
catch (HetznerCloudException failure)
{
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
// for why a fault at Hetzner's end is worded differently from a refused token even though both
// land here: one of them is something the person reading this can act on.
Status = failure.Message;
}
finally
{
// Cleared whatever happened, including on the way to a list somebody is about to import from:
// the read is over and the token has no further use. A failed attempt clears it too, so a
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
// against a credential sitting in a text box for the rest of the session.
Token = string.Empty;
IsBusy = false;
RaiseListState();
}
}
/// Stores the ticked servers as hosts.
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
if (Port is not { } port)
{
Status = "That is not a port. Give a number between 1 and 65535.";
return;
}
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
if (chosen.Count == 0)
{
Status = "Nothing is ticked.";
return;
}
IsBusy = true;
try
{
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
// leaves the host bound to nothing, which is what it always was.
var requests = chosen
.Select(row => new ImportedHostRequest(
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
Key: null,
KeyPath: null))
.ToList();
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
.ConfigureAwait(true);
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
// which is what makes coming back to the list and importing the rest of the project harmless
// and visible rather than harmless and confusing.
Refresh(resetSelection: true);
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
Step = HetznerImportStep.Done;
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// Back to the token step, with the list dropped.
///
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
/// means to have produced them would be showing a list it cannot stand behind.
///
[RelayCommand]
private void Restart()
{
ClearRows();
Status = string.Empty;
Step = HetznerImportStep.Token;
}
///
/// Back from the last step to the list, with what was just stored marked as already here.
///
///
/// Unlike this keeps the rows, and it can: they were read before the token was
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
/// otherwise.
///
[RelayCommand]
private void Review()
{
if (Rows.Count > 0)
{
Step = HetznerImportStep.Choose;
}
}
/// Points the whole import at one of the three kinds of address.
[RelayCommand]
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
/// Ticks or unticks every importable row at once.
[RelayCommand]
private void ToggleAll()
{
var importable = Rows.Where(row => row.CanImport).ToList();
var target = SelectedCount < importable.Count;
foreach (var row in importable)
{
row.IsSelected = target;
}
RaiseListState();
}
/// The footer's Cancel button: back to the Preferences page, nothing stored.
///
/// A delegate rather than a reference up to MainWindowViewModel, on the reasoning
/// 's own cancel gives: this type has no business knowing settings mode
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
/// no-op rather than a crash.
///
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
/// What the import did, in the sentence the screen keeps after it.
///
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
/// standing on. It names the vault and the key because those are the two answers that were chosen above
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
/// there is a key is what they would otherwise find out at the next connection.
///
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
{
(true, true) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
(true, false) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
+ "you bind it to a key.",
(false, true) =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
+ $"'{SelectedKey!.Label}'.",
_ =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
+ "password until you bind them to a key.",
};
/// Fills the list from what the project said, and resolves every row against the choices.
///
/// Its own method rather than part of , which is a request, a failure path and a
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
/// them and presses the button.
///
private void Fill(HetznerImport import)
{
foreach (var server in import.Servers)
{
var row = new HetznerRowViewModel(server);
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
// the item template, because a template binding cannot reach the list it is inside without a
// parent binding — and the count is on the parent by definition.
row.PropertyChanged += OnRowChanged;
Rows.Add(row);
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
Refresh(resetSelection: true);
}
///
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
///
///
///
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
///
///
/// Selections are carried across by id, which is the same rule the host list's own reload
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
/// the import would not actually write to. Anything that has since gone falls back to the default —
/// the active vault, and no key.
///
///
private void RefreshChoices()
{
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
var keyId = SelectedKey?.KeyId;
VaultChoices.Clear();
foreach (var choice in vault.TargetVaults)
{
VaultChoices.Add(choice);
}
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
?? VaultChoices.FirstOrDefault();
KeyChoices.Clear();
KeyChoices.Add(HetznerKeyChoice.None);
foreach (var key in vault.Keys)
{
// The vault name where there is more than one to be in, exactly as the key list itself prints
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
// has to say which. HasVaultBadge is that same test, already answered.
KeyChoices.Add(new HetznerKeyChoice(
key.EntityId,
key.VaultId,
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
}
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
OnPropertyChanged(nameof(HasVaultChoice));
OnPropertyChanged(nameof(HasKeyChoice));
}
/// Drops the list, unsubscribing from what it held.
///
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
///
private void ClearRows()
{
foreach (var row in Rows)
{
row.PropertyChanged -= OnRowChanged;
}
Rows.Clear();
Warnings.Clear();
RaiseListState();
}
///
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
/// started, which already ends in .
///
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
{
RaiseListState();
}
}
/// Re-resolves every row against the current address choice.
private void Refresh(bool resetSelection)
{
foreach (var row in Rows)
{
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
}
RaiseListState();
}
///
/// Whether the keychain already holds a host that would dial where this one does.
///
///
/// Matched on address, port and user rather than on name, for the reason
/// gives at more length: a machine bookmarked under
/// another name is the same machine, and matching on the label would offer a duplicate of something
/// already stored. Compared against the resolved host so that a stored host inheriting its port
/// or user from a group still matches.
///
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
&& existing.Resolved.Port.Value == Port
&& string.Equals(
existing.Resolved.Username.Value,
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
StringComparison.OrdinalIgnoreCase));
partial void OnStepChanged(HetznerImportStep value)
{
OnPropertyChanged(nameof(IsTokenStep));
OnPropertyChanged(nameof(IsChooseStep));
OnPropertyChanged(nameof(IsDoneStep));
OnPropertyChanged(nameof(StepLabel));
OnPropertyChanged(nameof(StepTitle));
}
///
/// The vault is named in three places on this step — the summary line, the note under it and the
/// sentence about a key left in another vault — and none of them is a control the picker owns.
///
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
{
OnPropertyChanged(nameof(VaultName));
OnPropertyChanged(nameof(SelectionSummary));
OnPropertyChanged(nameof(AuthenticationNote));
}
///
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
OnPropertyChanged(nameof(AuthenticationNote));
partial void OnAddressKindChanged(HetznerAddressKind value)
{
OnPropertyChanged(nameof(UsesPublicIPv4));
OnPropertyChanged(nameof(UsesPublicIPv6));
OnPropertyChanged(nameof(UsesPrivate));
OnPropertyChanged(nameof(AddressNote));
Refresh(resetSelection: false);
}
///
/// The user and the port are half of what "already here" is decided on, so changing either has to
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
/// would now store is different.
///
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
///
partial void OnPortTextChanged(string value)
{
OnPropertyChanged(nameof(IsPortValid));
Refresh(resetSelection: false);
}
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
private void RaiseListState()
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportableCount));
OnPropertyChanged(nameof(ImportLabel));
OnPropertyChanged(nameof(AllTicked));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(SelectionSummary));
}
}