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