using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>
/// Anything the host sidebar's one list can hold.
/// </summary>
/// <remarks>
/// <para>
/// A marker, because the list is one <c>ListBox</c> and has to stay one — it owns the selection, and it is
/// where keyboard focus lands when the terminal gives it back. Neither survives being split into a list per
/// group, which is why headings are rows rather than containers.
/// </para>
/// <para>
/// The cost is that a heading is selectable as far as the <c>ListBox</c> is concerned, and it must not be as
/// far as anything else is: <c>Connect</c>, <c>Edit</c> and <c>Delete</c> all read the host selection. See
/// <c>VaultViewModel.SelectedSidebarRow</c>, which is where a heading click is turned back into whatever was
/// selected before it.
/// </para>
/// </remarks>
internal interface ISidebarRow;

/// <summary>One group heading, as a row in the host list.</summary>
/// <param name="GroupId">The group, or null for the heading ungrouped hosts fall under.</param>
/// <param name="Label">What the heading says.</param>
/// <param name="Count">How many hosts are under it, after the filter.</param>
/// <param name="IsExpanded">Whether its hosts are showing.</param>
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
    : ISidebarRow
{
    /// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
    internal string Chevron => IsExpanded ? "▾" : "▸";
}

/// <summary>One group, as a row in the group list.</summary>
/// <remarks>
/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
/// count is computed from the host list rather than stored on the group — see <see cref="HostGroupSecret"/>
/// for why membership lives on the host — so it is passed in rather than read off the item.
/// </remarks>
internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, int hostCount)
{
    internal Guid EntityId => group.EntityId;

    internal HostGroupSecret Group => group.Secret;

    internal string Label => group.Secret.Label;

    internal int HostCount => hostCount;

    internal bool IsReadOnly => group.IsReadOnly;

    internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);

    /// <summary>What the row says under the name.</summary>
    internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}

/// <summary>An entry in the host editor's group picker.</summary>
/// <param name="EntityId">The group, or null for "no group".</param>
/// <param name="Label">What to show.</param>
/// <remarks>
/// A sentinel entry rather than a nullable selection, for the reason <see cref="AuthenticationChoice"/> gives:
/// a ComboBox with nothing selected and a ComboBox meaning "no group" look identical and are not the same
/// thing. As there, a group the vault no longer has keeps a placeholder entry, so that editing a host's port
/// cannot quietly unfile it.
/// </remarks>
internal sealed record GroupChoice(Guid? EntityId, string Label)
{
    /// <summary>The "not in a group" entry, always first.</summary>
    internal static GroupChoice None { get; } = new(null, "No group");
}

/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
/// <remarks>
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
/// carries the vault the edit has to return to.
/// </remarks>
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);

/// <summary>One snippet, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
/// per click.
/// </remarks>
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
{
    internal Guid EntityId => snippet.EntityId;

    internal SnippetSecret Snippet => snippet.Secret;

    internal string Label => snippet.Secret.Label;

    internal bool RunsOnInsert => snippet.Secret.RunsOnInsert;

    internal bool IsReadOnly => snippet.IsReadOnly;

    internal bool HasUnsyncedChanges => snippet.HasUnsyncedChanges;

    internal string Badge => ItemBadge.For(snippet.IsBlocked, snippet.IsReadOnly, snippet.HasUnsyncedChanges);

    /// <summary>
    /// The command, on one line, for the list.
    /// </summary>
    /// <remarks>
    /// Newlines become <c>⏎</c> rather than being dropped or wrapped. A three-line snippet shown as one run
    /// of text would read as a single command, which is exactly the thing the user is deciding about when
    /// they look at this row.
    /// </remarks>
    internal string Preview => snippet.Secret.Command
        .ReplaceLineEndings("\n")
        .Replace("\n", " ⏎ ", StringComparison.Ordinal)
        .Trim();
}

/// <summary>One tag, as a row in the keychain table.</summary>
/// <remarks>
/// The narrowest row on that screen — a tag is a name — and it is there rather than only inside the host
/// editor for the reason the type exists at all: a tag is worth being an item because renaming it is one
/// write instead of twenty, and a rename needs somewhere to happen. Deleting needs somewhere too, or the
/// picker fills with names nobody uses and never empties.
/// </remarks>
internal sealed class TagRowViewModel(VaultItem<TagSecret> tag, int hostCount)
{
    internal Guid EntityId => tag.EntityId;

    internal TagSecret Tag => tag.Secret;

    internal string Label => tag.Secret.Label;

    internal int HostCount => hostCount;

    internal bool IsReadOnly => tag.IsReadOnly;

    internal bool HasUnsyncedChanges => tag.HasUnsyncedChanges;

    internal string Badge => ItemBadge.For(tag.IsBlocked, tag.IsReadOnly, tag.HasUnsyncedChanges);

    /// <summary>What the row says under the name.</summary>
    /// <remarks>
    /// The count, because it is the only fact a tag has beyond its name and it is the one that decides
    /// whether deleting it matters. A tag on nothing is a tidy-up; a tag on twenty machines is a filter
    /// somebody relies on.
    /// </remarks>
    internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}

/// <summary>
/// One tag in the host editor's picker, and whether this host wears it.
/// </summary>
/// <param name="EntityId">The tag item.</param>
/// <param name="Label">What to show on the chip.</param>
/// <param name="IsWorn">Whether the host being edited currently carries it.</param>
/// <remarks>
/// <para>
/// A chip that toggles rather than a multi-select list, because that is what a tag looks like everywhere
/// else on both heads — the row already draws worn tags as chips, and a picker drawn as anything else
/// would make the user match a list entry to a chip they can see two inches away.
/// </para>
/// <para>
/// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents.
/// A mutable <c>IsWorn</c> would need change notification on every chip in the vault to make one toggle
/// redraw.
/// </para>
/// </remarks>
internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn);

/// <summary>One host, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
/// </remarks>
internal sealed partial class HostRowViewModel(
    VaultItem<HostSecret> host,
    ResolvedHost resolved,
    IReadOnlyList<string> tagLabels,
    Guid vaultId,
    string vaultName) : ObservableObject, ISidebarRow
{
    internal Guid EntityId => host.EntityId;

    /// <summary>
    /// Which vault this host lives in.
    /// </summary>
    /// <remarks>
    /// Carried on the row rather than read from the session, because a session now holds several and an
    /// edit has to return to the vault the item came from. Writing it to the active vault instead would
    /// create a second copy in the personal vault and leave the team's original untouched — a silent fork
    /// that only shows up when somebody else wonders why their change never arrived.
    /// </remarks>
    internal Guid VaultId => vaultId;

    /// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
    internal string VaultName => vaultName;

    /// <summary>
    /// The vault name to print on this row, or empty when there is only one vault to be in.
    /// </summary>
    /// <remarks>
    /// Decided by the list rather than by the row, because "is there more than one vault" is not
    /// something a row can see — and the alternative, a binding that reaches out to the parent view
    /// model from inside an item template, is the kind of thing that silently resolves to nothing.
    /// </remarks>
    internal string VaultBadge { get; init; } = string.Empty;

    /// <summary>Whether this row has a vault to name.</summary>
    internal bool HasVaultBadge => VaultBadge.Length > 0;

    internal HostSecret Host => host.Secret;

    /// <summary>
    /// The same host with its group chain applied: what it dials, not what was typed into it.
    /// </summary>
    /// <remarks>
    /// Resolved once, when the list is built, rather than per property. Every label on this row wants the
    /// same three answers, and re-walking the chain for each of them would be a walk per property per redraw.
    /// It also means a row cannot disagree with itself — the address the list shows and the port the connect
    /// command dials come from one value.
    /// </remarks>
    internal ResolvedHost Resolved => resolved;

    /// <summary>
    /// The names of the tags this host wears, in the order the chips are drawn.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Names rather than ids, resolved once when the list is built, because a chip shows a name and the
    /// host stores an id — and resolving per chip per redraw would be a dictionary lookup per tag per row
    /// per frame for a string that cannot change without the list being rebuilt.
    /// </para>
    /// <para>
    /// <b>A tag that does not resolve is left out rather than drawn as its id.</b> It means the tag was
    /// deleted on another machine, or belongs to a vault this session cannot read; either way the honest
    /// answer is a host with one chip fewer, not one wearing a GUID. That the host still carries the id is
    /// the point — nothing rewrites twenty payloads to clear one deleted tag, so the chip comes back if the
    /// tag does. See <see cref="HostSecret.TagIds"/>.
    /// </para>
    /// </remarks>
    internal IReadOnlyList<string> TagLabels => tagLabels;

    /// <summary>Whether this host has any chips to draw.</summary>
    internal bool HasTags => tagLabels.Count > 0;

    internal string Label => host.Secret.Label;

    /// <summary>
    /// What this row dials, as one string.
    /// </summary>
    /// <remarks>
    /// The <em>resolved</em> address, which is not decoration: <c>MainWindowViewModel.Rank</c> searches this,
    /// so a host inheriting 2222 that displayed 22 would be unfindable by the port it actually answers on —
    /// and the user would be searching for the number the machine really uses.
    /// </remarks>
    internal string Address => string.Create(
        CultureInfo.InvariantCulture,
        $"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");

    /// <remarks>
    /// An em dash for "nobody", which covers both a host that states no username and a host whose group
    /// states none either. The two are the same thing to look at and the same thing at connect time: refused,
    /// with a message asking for one.
    /// </remarks>
    private string DisplayUsername =>
        string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;

    internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;

    internal bool IsBlocked => host.IsBlocked;

    internal bool IsReadOnly => host.IsReadOnly;

    /// <summary>How this host authenticates, in one word.</summary>
    /// <remarks>
    /// <para>
    /// Worth a word in the list because the three behave differently at the moment of connecting: only one of
    /// them needs the password box filled in, and a user staring at an empty password box on a
    /// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which
    /// is why the stored kind is "credential" rather than a second sort of password.
    /// </para>
    /// <para>
    /// Read from the resolved binding rather than the host's own two ids, because the question this answers
    /// is "will the password box be used" — and a host that inherits its group's key would otherwise say
    /// "password" while quietly not needing one. Where the binding came from a group the word is the same;
    /// the group is named in <see cref="VaultViewModel.SelectedHostAuthenticationNote"/>, which has room for
    /// a sentence rather than a word.
    /// </para>
    /// </remarks>
    internal string Authentication => resolved.Binding.Kind switch
    {
        ResolvedBindingKind.Credential => "credential",
        ResolvedBindingKind.SshKey => "key",
        _ => "password",
    };

    /// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
    internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);

    /// <summary>
    /// Whether a terminal is open on this host right now.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The one thing on a host row that is not a property of the host. It is written by the shell, which owns
    /// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one
    /// holding the answer. The design's status dot is this, and it is the reason the row is observable at
    /// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright.
    /// </para>
    /// <para>
    /// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
    /// would be a claim this application never checks.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private bool isConnected;
}

/// <summary>What a host can authenticate with.</summary>
internal enum AuthenticationKind
{
    /// <summary>Typed at the moment of connecting.</summary>
    /// <remarks>
    /// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
    /// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
    /// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
    /// <see cref="VaultViewModel.RemembersConnectPassword"/>.
    /// </remarks>
    Typed,

    /// <summary>An SSH key in this vault.</summary>
    SshKey,

    /// <summary>A username and password in this vault.</summary>
    Credential,

    /// <summary>
    /// Whatever the group above this host says, or a typed password if it says nothing.
    /// </summary>
    /// <remarks>
    /// The entry that arrived with inheritance, and the reason <see cref="HostSecret.AsksForPassword"/>
    /// exists. Naming neither a key nor a credential used to be the way to say "type one each time"; it now
    /// means this, so the old meaning needs a value of its own — otherwise a host under a group that binds a
    /// key could not opt out of it.
    /// </remarks>
    Inherited,
}

/// <summary>An entry in the host editor's authentication picker.</summary>
/// <param name="Kind">Which of the three ways this entry means.</param>
/// <param name="EntityId">The bound item's id, or null for a typed password.</param>
/// <param name="Label">What to show.</param>
/// <param name="Qualifier">
/// What kind of thing the label names, shown beside it. Empty for the typed-password entry, which is not a
/// thing in the vault.
/// </param>
/// <remarks>
/// <para>
/// <b>One picker for all three, not two pickers.</b> A host authenticates with a key <i>or</i> a stored
/// credential <i>or</i> a typed password, never two of them — <c>HostSecret.TryValidate</c> refuses a host
/// naming both. Two controls would express the illegal state and then reject it at save time; one control
/// cannot express it at all. That is the same reason this is a sentinel entry rather than a nullable
/// selection: a ComboBox with nothing selected and a ComboBox meaning "type a password" look identical and
/// are not the same thing — the first is a host whose binding has not been decided, the second is a decision.
/// </para>
/// <para>
/// The qualifier is load-bearing rather than decoration. Keys and credentials are named by the user and often
/// named the same thing — a key called <c>deploy</c> and the deploy account's password are the ordinary case —
/// so a list of bare labels would offer two indistinguishable entries that authenticate completely
/// differently.
/// </para>
/// </remarks>
internal sealed record AuthenticationChoice(
    AuthenticationKind Kind,
    Guid? EntityId,
    string Label,
    string Qualifier)
{
    /// <summary>The "type it each time" entry, always first.</summary>
    /// <remarks>
    /// Named for what it costs rather than for what it lacks. "Password (no key)" described the old two-way
    /// choice from the key's side; with credentials in the same list the distinction a user needs is between a
    /// password this vault knows and one they will be asked for.
    /// </remarks>
    internal static AuthenticationChoice Typed { get; } =
        new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty);

    /// <summary>The "whatever the group says" entry, offered only to a host that is in one.</summary>
    /// <remarks>
    /// Named for where the answer comes from rather than for what it will be, because what it will be
    /// changes when somebody edits the group — which is the point of it. It is deliberately not offered to
    /// an ungrouped host: with nothing above it this and <see cref="Typed"/> do the same thing, and two
    /// entries that behave identically are two entries a user has to guess between.
    /// </remarks>
    internal static AuthenticationChoice Inherited { get; } =
        new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty);

    /// <summary>The "this group lends no binding" entry, first in the group editor's picker.</summary>
    /// <remarks>
    /// A sentinel rather than a null selection, for the reason <see cref="Typed"/> is one: a picker showing
    /// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is
    /// not <see cref="Typed"/> under another name — a group cannot assert "everything under here types its
    /// password", because that is already what a host gets when the chain lends nothing.
    /// </remarks>
    internal static AuthenticationChoice NoDefault { get; } =
        new(AuthenticationKind.Typed, null, "No default binding", string.Empty);

    /// <summary>An SSH key that is in the vault.</summary>
    internal static AuthenticationChoice ForKey(Guid entityId, string label) =>
        new(AuthenticationKind.SshKey, entityId, label, "SSH key");

    /// <summary>A credential that is in the vault.</summary>
    internal static AuthenticationChoice ForCredential(Guid entityId, string label) =>
        new(AuthenticationKind.Credential, entityId, label, "credential");

    /// <summary>
    /// A stand-in for something the host names and the vault no longer has.
    /// </summary>
    /// <remarks>
    /// Kept in the list, and kept selected, so that opening a host to change its port does not silently
    /// convert it to a typed password on save. The id and the kind are both preserved — the kind because
    /// dropping it would rebind a dangling credential as a dangling key — and only the label admits the
    /// problem.
    /// </remarks>
    internal static AuthenticationChoice Missing(AuthenticationKind kind, Guid entityId) => new(
        kind,
        entityId,
        kind is AuthenticationKind.Credential
            ? "(a credential that is no longer here)"
            : "(a key that is no longer here)",
        kind is AuthenticationKind.Credential ? "credential" : "SSH key");
}

/// <summary>One SSH key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// Carries the decrypted <see cref="SshKeySecret"/>, as the host row carries its host, so opening the
/// editor or connecting with the key needs no second decryption.
/// </para>
/// <para>
/// <b>Nothing here exposes the private key to the view.</b> <see cref="Key"/> is what the editor and the
/// connect path read, and the members the XAML binds are the label, a description and a badge. That is not
/// a security boundary — the same object holds the material either way — but it does mean no template,
/// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious
/// property.
/// </para>
/// </remarks>
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
{
    internal Guid EntityId => key.EntityId;

    /// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
    internal Guid VaultId => vaultId;

    /// <summary>The vault's display name.</summary>
    internal string VaultName => vaultName;

    internal SshKeySecret Key => key.Secret;

    internal string Label => key.Secret.Label;

    /// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
    internal string Description => key.Secret switch
    {
        { Passphrase: not null, PublicKey: not null } => "passphrase · public half stored",
        { Passphrase: not null } => "passphrase · no public half",
        { PublicKey: not null } => "no passphrase · public half stored",
        _ => "no passphrase · no public half",
    };

    internal bool HasUnsyncedChanges => key.HasUnsyncedChanges;

    internal bool IsBlocked => key.IsBlocked;

    internal bool IsReadOnly => key.IsReadOnly;

    internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
}

/// <summary>One stored credential, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The same arrangement as <see cref="SshKeyRowViewModel"/>, for the same two reasons: the decrypted secret
/// travels with the row so opening the editor or connecting needs no second decryption, and
/// <b>nothing here exposes the password to the view</b>. <see cref="Credential"/> is what the editor and the
/// connect path read; what the XAML binds is a label, a description and a badge. Not a security boundary —
/// the same object holds the password either way — but it means no template, tooltip or accessibility surface
/// can render a password by being pointed at the obvious property.
/// </para>
/// </remarks>
/// <summary>One bucket, as a row in the list.</summary>
internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store)
{
    internal Guid EntityId => store.EntityId;

    internal ObjectStoreSecret Store => store.Secret;

    internal string Label => store.Secret.Label;

    /// <summary>What the list shows under the name: where it is, never the keys.</summary>
    /// <remarks>
    /// The bucket and the service, because those are what tell two entries apart — the same bucket name in
    /// two accounts is the ordinary case. The access key id is an identifier rather than a secret and is
    /// still not here: it is long, it is noise in a list, and it belongs in the detail pane.
    /// </remarks>
    internal string Description => store.Secret.Endpoint is { } endpoint
        ? $"{store.Secret.Bucket} at {endpoint}"
        : $"{store.Secret.Bucket} · {store.Secret.Region}";

    internal bool HasUnsyncedChanges => store.HasUnsyncedChanges;

    internal bool IsBlocked => store.IsBlocked;

    internal bool IsReadOnly => store.IsReadOnly;

    internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
}

internal sealed class CredentialRowViewModel(
    VaultItem<CredentialSecret> credential,
    Guid vaultId,
    string vaultName)
{
    internal Guid EntityId => credential.EntityId;

    /// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
    internal Guid VaultId => vaultId;

    /// <summary>The vault's display name.</summary>
    internal string VaultName => vaultName;

    internal CredentialSecret Credential => credential.Secret;

    internal string Label => credential.Secret.Label;

    /// <summary>What the list shows under the name: the account, never the password.</summary>
    /// <remarks>
    /// The username is the whole reason a credential is a separate item rather than two fields on a host, so
    /// it is what the row has to show. Absent means "whatever the host says", which is a different statement
    /// from a blank one and is written out rather than left as an empty line.
    /// </remarks>
    internal string Description => credential.Secret.Username is { } username
        ? username
        : "uses each host's own username";

    internal bool HasUnsyncedChanges => credential.HasUnsyncedChanges;

    internal bool IsBlocked => credential.IsBlocked;

    internal bool IsReadOnly => credential.IsReadOnly;

    internal string Badge =>
        ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges);
}

/// <summary>The one-word marker a row shows for its sync state.</summary>
/// <remarks>
/// Shared by both row types rather than written twice, because the three states mean the same thing for
/// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two
/// different conditions.
/// </remarks>
internal static class ItemBadge
{
    internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) =>
        (isBlocked, isReadOnly, hasUnsyncedChanges) switch
        {
            (true, _, _) => "rejected",
            (_, true, _) => "newer version",
            (_, _, true) => "not synced",
            _ => string.Empty,
        };
}

/// <summary>
/// A terminal session that has just opened.
/// </summary>
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">The account and endpoint actually dialled.</param>
internal sealed class TerminalSessionEventArgs(
    Guid attemptId,
    uint sessionId,
    string label,
    string address) : EventArgs
{
    /// <summary>Which attempt this session came out of.</summary>
    /// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
    internal Guid AttemptId { get; } = attemptId;

    internal uint SessionId { get; } = sessionId;

    internal string Label { get; } = label;

    internal string Address { get; } = address;
}

/// <summary>A connection that has been asked for, and has not answered yet.</summary>
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param>
/// <remarks>
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
/// has no vault to read it from.
/// </remarks>
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
{
    /// <summary>Identifies this attempt for the whole of its life.</summary>
    /// <remarks>
    /// Carried by all three events, because several connections can be in flight at once now that one no
    /// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one".
    /// </remarks>
    internal Guid AttemptId { get; } = attemptId;

    internal string Label { get; } = label;

    internal string Address { get; } = address;
}

/// <summary>A connection that was asked for and did not happen.</summary>
/// <param name="attemptId">The attempt that has just ended.</param>
/// <param name="reason">What to say about it, in the tab.</param>
/// <param name="isAwaitingAnAnswer">
/// Whether the connection stopped on a question rather than on a failure.
/// </param>
/// <remarks>
/// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and
/// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something
/// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown
/// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection
/// resumes the moment it is answered — so the tab goes and the window shows the question instead.
/// </remarks>
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
    : EventArgs
{
    /// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
    internal Guid AttemptId { get; } = attemptId;

    internal string Reason { get; } = reason;

    /// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
    internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
}

/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
    internal Guid Id => notice.Id;

    internal string Summary => notice.Summary;

    /// <summary>
    /// The overridden values, one line each.
    /// </summary>
    /// <remarks>
    /// This is the whole justification for resolving a conflict automatically. If these were not shown,
    /// the merge would be last-writer-wins with a longer explanation.
    /// </remarks>
    // The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual
    // keyword inside a property accessor, and this whole expression is one.
    internal string Detail => notice.Fields.Count == 0
        ? string.Empty
        : string.Join(
            Environment.NewLine,
            notice.Fields.Select(entry => entry.DiscardedWasRemoval
                ? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
                : $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));

    internal bool HasDetail => notice.Fields.Count > 0;
}

/// <summary>
/// Which kind of item the vault column is showing.
/// </summary>
/// <remarks>
/// One at a time, chosen by a selector at the top of the column. The alternative was every kind stacked in
/// one scrolling column, which is what the column did with two of them and would not survive a third: two
/// lists and two editors already only just fit at the window's minimum height, and credentials are coming.
/// A section is also the unit the column's own layout is measured in — see
/// <c>DodoSSH.Client.App.Layout.Tests</c>, which lays out one of these at a time because that is all a user
/// can ever see at once.
/// </remarks>
internal enum VaultSection
{
    /// <summary>Every kind at once, which is where the screen opens.</summary>
    All,

    /// <summary>The SSH keys hosts authenticate with.</summary>
    Keys,

    /// <summary>The usernames and passwords they authenticate with instead.</summary>
    /// <remarks>
    /// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see
    /// <c>KnownHostsScreen</c> — and the member is gone rather than left unused, because a value this
    /// screen's command would still accept and no longer draw anything for is a trap with no upside.
    /// </remarks>
    Credentials,

    /// <summary>The tags hosts wear.</summary>
    /// <remarks>
    /// The odd one out, and here anyway. A tag holds no secret at all — it is a name, and the reason it is
    /// an item rather than a string on a host is that renaming it should be one write instead of twenty.
    /// That rename needs somewhere to happen, and deleting does too, or the host editor's picker fills with
    /// names nobody uses and never empties. This is where every other item kind already lives.
    /// </remarks>
    Tags,

    /// <summary>S3-compatible buckets, and the keys that reach them.</summary>
    /// <remarks>
    /// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something
    /// somebody creates and edits and whose secret has to be kept, which is exactly what the other two
    /// categories are. A pin is not.
    /// </remarks>
    Buckets,
}

/// <summary>What kind of thing a row in the vault table is.</summary>
/// <remarks>
/// Two, since the pins left. Both are things somebody created on purpose and can edit, which is what the
/// table's shared shape now assumes throughout.
/// </remarks>
internal enum VaultItemKind
{
    /// <summary>An SSH key.</summary>
    Key,

    /// <summary>A stored username and password.</summary>
    Credential,

    /// <summary>An S3-compatible bucket.</summary>
    ObjectStore,

    /// <summary>A tag a host can wear.</summary>
    Tag,
}

/// <summary>
/// One row of the vault table, whatever kind of item it is.
/// </summary>
/// <remarks>
/// <para>
/// The table has one shape and a <c>TYPE</c> column, which is what lets a single category show every kind
/// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row
/// rather than a replacement for one: selecting here sets the typed selection the editors and the delete
/// commands already act on, so nothing downstream had to learn about a second way of naming an item.
/// </para>
/// <para>
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> an item — whether a
/// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key
/// or the password itself. The same rule the three list templates already followed, now in one place where
/// it is harder to break by pointing a template at the obvious property.
/// </para>
/// </remarks>
/// <param name="Kind">Which of the three lists this came from.</param>
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
/// <param name="Name">The label the user gave it.</param>
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
/// <param name="Detail">What is known about it.</param>
/// <param name="Badge">Its sync state, or empty.</param>
/// <param name="HasUnsyncedChanges">
/// Whether this machine has a change to this item that the server has not accepted. Carried separately from
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
/// by comparing it against the literal "not synced" would break the day that wording improves.
/// </param>
/// <summary>One vault, as an option in the "file this into" picker.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
{
    /// <summary>
    /// What the picker shows.
    /// </summary>
    /// <remarks>
    /// A team vault is marked as one. The whole risk this picker introduces is putting a credential
    /// somewhere more people can read it, so the option that does that must not look like the option
    /// that does not.
    /// </remarks>
    internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
}

internal sealed record VaultItemRowViewModel(
    VaultItemKind Kind,
    Guid EntityId,
    string Name,
    string Type,
    string Detail,
    string Badge,
    bool HasUnsyncedChanges)
{
    /// <summary>Whether this row has anything to say about its sync state.</summary>
    internal bool HasBadge => Badge.Length > 0;
}

/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
internal enum DeletionTarget
{
    /// <summary>A host, from the sidebar beside the terminal.</summary>
    Host,

    /// <summary>An SSH key, from the vault screen.</summary>
    Key,

    /// <summary>A stored password, from the vault screen.</summary>
    Credential,

    /// <summary>A group, from the panel beside the host sidebar.</summary>
    Group,

    /// <summary>A bucket, from the vault screen.</summary>
    ObjectStore,

    /// <summary>A tag, from the vault screen.</summary>
    Tag,
}

/// <summary>
/// A deletion that has been asked for and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
/// below are <em>computed</em>: how many hosts authenticate with the key about to go, whether a terminal is
/// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that
/// only said "are you sure?" would be a click to train people out of.
/// </para>
/// <para>
/// It carries the item's id rather than pointing at the selection, so that whatever moves the selection
/// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an
/// agreement about one item into the deletion of another.
/// </para>
/// </remarks>
/// <param name="Target">Which list to delete from.</param>
/// <param name="EntityId">The item the question is about.</param>
/// <param name="Question">The question itself, naming the item.</param>
/// <param name="Consequence">Where it goes, and how far.</param>
/// <param name="Usage">
/// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or
/// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares.
/// </param>
internal sealed record DeletionRequest(
    DeletionTarget Target,
    Guid EntityId,
    string Question,
    string Consequence,
    string Usage)
{
    /// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
    internal bool HasUsage => Usage.Length > 0;
}

/// <summary>
/// Gets this machine online, if it can be.
/// </summary>
/// <remarks>
/// <para>
/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
/// of reaching a network, with nothing pressed.
/// </para>
/// <para>
/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
/// locally and queue.
/// </para>
/// </remarks>
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);

/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
/// <remarks>
/// <para>
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
/// delete disappears immediately whether or not the network is there. Nothing here waits on a server to
/// show a change.
/// </para>
/// <para>
/// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a
/// timer while it stays open. The Sync button remains, because a person who has just been handed a
/// credential wants to know now rather than within the minute — but nothing depends on it being pressed.
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
/// </para>
/// <para>
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
/// shell for a connection rather than reading one it was handed at unlock — see
/// <see cref="ServerReconnectHandler" /> — so a machine that unlocked with no network comes online by
/// itself once it has one, and a sign-in survives a restart without a browser opening.
/// </para>
/// <para>
/// <b>Everything a connection needs is in the vault.</b> Keys, passwords and host key trust are all synced
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
/// that is now a choice rather than the only option this interface offered.
/// </para>
/// <para>
/// <b>A key or a password belongs to a host.</b> Each host names the one thing it authenticates with, or
/// nothing, and that choice is a field in its encrypted payload — so it follows the host to every machine
/// rather than being made again per connection. The two are mutually exclusive and the editor offers them
/// through a single picker, which is what makes the illegal combination unrepresentable rather than merely
/// invalid. The cost is a payload schema version, paid only by hosts that actually bind something: see
/// <c>HostSecretCodec.CurrentSchemaVersion</c>.
/// </para>
/// </remarks>
/// <param name="copyToClipboard">
/// Puts one line of text on the system clipboard, or null where there is none.
/// <para>
/// A delegate rather than Avalonia's <c>IClipboard</c>, for the reason <c>SignInHandler</c> is one: the
/// clipboard is reached through <c>TopLevel.GetTopLevel(control)</c>, so taking it directly would make this
/// view model need a visual — and every test that drives it need a window. Null is a machine that has no
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
/// </para>
/// </param>
internal sealed partial class VaultViewModel(
    VaultSession session,
    TerminalWorkspace workspace,
    VaultKnownHostStore knownHosts,
    Func<IVaultServer?> connection,
    ServerReconnectHandler? reconnect = null,
    Func<string, Task>? copyToClipboard = null,
    ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
{
    /// <remarks>
    /// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
    /// server almost nothing; the number that matters is how stale a teammate's change may look, and a
    /// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
    /// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
    /// </remarks>
    private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);

    /// <summary>How often the logs are pruned, at most.</summary>
    /// <remarks>
    /// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
    /// and thousands of entries, so a pass that ran six hours late has nothing to catch up on — and one that
    /// ran every minute would be a machine talking to a server all day about housekeeping.
    /// </remarks>
    private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6);

    /// <summary>When the logs were last pruned, or null when this session has not pruned yet.</summary>
    private DateTimeOffset? lastPruned;

    /// <summary>Serialises every synchronisation pass, whether a button pressed it or a timer did.</summary>
    private readonly SemaphoreSlim syncGate = new(1, 1);

    /// <summary>
    /// The groups as they came out of the vault, before the host counts are attached.
    /// </summary>
    /// <remarks>
    /// Held between the group reload and the host reload, which are two passes because a group row says how
    /// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>.
    /// </remarks>
    private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];

    /// <summary>
    /// The same groups by id, which is the shape the inheritance walk takes.
    /// </summary>
    /// <remarks>
    /// Cached beside <see cref="groupItems"/> rather than built per call, because resolving is on the path
    /// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
    /// would be one allocation per row per redraw.
    /// </remarks>
    private Dictionary<Guid, HostGroupSecret> groupsById = [];

    /// <summary>
    /// The tags as they came out of the vault, before the host counts are attached.
    /// </summary>
    /// <inheritdoc cref="groupItems" path="/remarks" />
    private IReadOnlyList<VaultItem<TagSecret>> tagItems = [];

    /// <summary>
    /// The same tags by id, which is what a host's <see cref="HostSecret.TagIds"/> resolves through.
    /// </summary>
    /// <remarks>
    /// Every readable vault's, like <see cref="groupsById"/> and for a weaker version of the same reason.
    /// A tag id that does not resolve draws no chip, so a host in a team's vault would silently look
    /// untagged — which costs a label rather than a connection, but is just as confusing to look at and
    /// costs nothing to avoid.
    /// </remarks>
    private Dictionary<Guid, TagSecret> tagsById = [];

    /// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
    private readonly HashSet<Guid> collapsedGroups = [];

    private CancellationTokenSource? autoSync;
    private Task? autoSyncLoop;
    private bool disposed;

    /// <summary>
    /// The open vault this view model is showing.
    /// </summary>
    /// <remarks>
    /// Exposed for the operations only an open session can perform — sealing the bundle to a new device key,
    /// chiefly — which the shell drives rather than this view model. Ownership does not move: this type
    /// disposes it, and a caller must not.
    /// </remarks>
    internal VaultSession Session => session;

    /// <summary>The hosts to show, unpushed local state included.</summary>
    /// <remarks>
    /// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
    /// host key list checks itself against, so a filter applied here would change what the application can
    /// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];

    /// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
    /// <remarks>
    /// A second collection rather than a filtered view over the first, because the sidebar's list has to be
    /// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
    /// terminal gives it back, and neither of those survives being split across several lists.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];

    /// <summary>Whether the grid has anything to draw.</summary>
    /// <remarks>
    /// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
    /// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
    /// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
    /// </remarks>
    internal bool HasVisibleHosts => VisibleHosts.Count > 0;

    /// <summary>
    /// What the hosts grid says when it has nothing in it.
    /// </summary>
    /// <remarks>
    /// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
    /// matches what you typed" are three different situations and only the first is an invitation to add
    /// something. Telling somebody with thirty machines to add their first one is answering a question they
    /// did not ask.
    /// </remarks>
    internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
    {
        (0, _, _) =>
            "No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
            + "~/.ssh/config from Preferences.",
        (_, not null, 0) =>
            "Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
            + "group in a host's own editor.",
        _ => "No host matches that. The name, the address and the notes are all searched.",
    };

    /// <summary>
    /// What the sidebar's list actually holds: the visible hosts, with group headings between them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>A vault with no groups produces no headings at all</b>, so this is <see cref="VisibleHosts"/> in
    /// the same order, and the sidebar looks exactly as it did before groups existed. The feature is
    /// invisible until it is used, which is the point: somebody with eleven machines and no wish to file them
    /// should not be shown a heading saying so.
    /// </para>
    /// <para>
    /// Kept beside <see cref="VisibleHosts"/> rather than replacing it. The count on the section heading, the
    /// filter's own arithmetic and every test that asks what the sidebar is showing all mean hosts — a
    /// collection whose <c>Count</c> silently included headings would be wrong in each of them.
    /// </para>
    /// </remarks>
    internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];

    /// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
    internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];

    /// <summary>The saved commands in this vault, unpushed local state included.</summary>
    /// <remarks>
    /// Held here rather than on the screen that shows them, for the reason every other list is: this is where
    /// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it
    /// to be forgotten. <c>SnippetsViewModel</c> is the filter and the editor over the top.
    /// </remarks>
    internal ObservableCollection<SnippetRowViewModel> Snippets { get; } = [];

    /// <summary>
    /// What the sidebar's one group heading says.
    /// </summary>
    /// <remarks>
    /// The vault's name, because the vault is the only grouping a host has — there are no tags and no
    /// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
    /// presented as structure.
    /// <para>
    /// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
    /// several, and then the heading stops naming one of them and each row names its own — a heading that
    /// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
    /// interface is otherwise careful about.
    /// </para>
    /// </remarks>
    internal string HostsHeading =>
        session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();

    /// <summary>The SSH keys to show, unpushed local state included.</summary>
    internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];

    /// <summary>The stored credentials to show, unpushed local state included.</summary>
    internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];

    /// <summary>The buckets to show, unpushed local state included.</summary>
    internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];

    /// <summary>The tags to show, with the number of hosts wearing each.</summary>
    /// <remarks>
    /// Counted like a group's rows are, and read after the hosts for the same reason: the count is a
    /// property of the host list rather than of the tag.
    /// </remarks>
    internal ObservableCollection<TagRowViewModel> Tags { get; } = [];

    /// <summary>The host keys this user has approved.</summary>
    internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];

    /// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
    internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];

    internal string VaultName =>
        session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";

    /// <summary>
    /// The vaults a new item may be filed into: readable, and writable by this account.
    /// </summary>
    /// <remarks>
    /// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
    /// it can read but not write is a team vault this member is a viewer of — offering either would end
    /// in a Save that fails, one of them locally and one at the server.
    /// </remarks>
    internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];

    /// <summary>
    /// Where the next new item goes.
    /// </summary>
    /// <remarks>
    /// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
    /// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
    /// visible to people who should not have it, and moving it afterwards means deleting and retyping.
    /// </remarks>
    internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;

    /// <summary>Whether there is more than one vault to choose between.</summary>
    /// <remarks>
    /// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
    /// a question with no answer, and for most people this stays at one for ever.
    /// </remarks>
    internal bool HasVaultChoice => TargetVaults.Count > 1;

    [ObservableProperty]
    private VaultChoiceViewModel? selectedTargetVault;

    [ObservableProperty]
    private HostRowViewModel? selectedHost;

    /// <summary>
    /// What the sidebar's <c>ListBox</c> has selected, which may be a heading.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The list holds two kinds of row and only one of them is a host, so the control's selection and the
    /// application's selection are no longer the same thing. This is the control's; <see cref="SelectedHost"/>
    /// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on
    /// reading that one.
    /// </para>
    /// <para>
    /// A heading click is bounced back to whatever was selected before it rather than being left highlighted
    /// or clearing the selection. Clearing would mean the buttons at the foot of the sidebar quietly stopped
    /// working because somebody folded a group away; leaving it highlighted would mean a selected row that
    /// none of those buttons act on.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private ISidebarRow? selectedSidebarRow;

    [ObservableProperty]
    private HostGroupRowViewModel? selectedGroup;

    /// <summary>
    /// The group the hosts grid is narrowed to, or null for every host.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
    /// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
    /// an explicit control rather than a second press on the chosen card: the cards are a
    /// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
    /// application uses, and a <c>ListBox</c> does not unselect on a second click.
    /// </para>
    /// <para>
    /// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
    /// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
    /// card means both, which is why the change handler assigns one from the other. They are not one
    /// property because the phone sets <see cref="SelectedGroup"/> on its own account:
    /// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
    /// would have made opening that editor silently filter the phone's host list to the group being renamed.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private HostGroupRowViewModel? groupFilter;

    /// <summary>Whether the grid is showing one group rather than every host.</summary>
    internal bool IsFilteredByGroup => GroupFilter is not null;

    /// <summary>Shows every host again.</summary>
    [RelayCommand]
    private void ClearGroupFilter()
    {
        GroupFilter = null;
        SelectedGroup = null;
    }

    /// <summary>What the group name box holds, for both creating and renaming.</summary>
    [ObservableProperty]
    private string groupEditorLabel = string.Empty;

    /// <summary>
    /// The port hosts in this group take when they state none, empty for no default.
    /// </summary>
    /// <remarks>
    /// Empty is a real answer here rather than a missing one: a group that states no port lends none, and
    /// the hosts beneath it walk further up. Distinguishing that from 22 is the difference between "these
    /// machines are on 22" and "these machines have not been told".
    /// </remarks>
    [ObservableProperty]
    private int? groupEditorDefaultPort;

    /// <summary>The user hosts in this group log in as when they state none.</summary>
    /// <inheritdoc cref="GroupEditorDefaultPort" path="/remarks" />
    [ObservableProperty]
    private string groupEditorDefaultUsername = string.Empty;

    /// <summary>
    /// What the group's parent picker offers: "no parent", then every group that may legally be one.
    /// </summary>
    /// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
    internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];

    [ObservableProperty]
    private GroupChoice? groupEditorSelectedParent;

    /// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
    /// <remarks>
    /// The host picker's list without its first entry. A group cannot default to "ask for a password each
    /// time": that is what a host beneath it gets when nothing in the chain lends a binding, so the entry
    /// would be indistinguishable from leaving this alone — and two controls that do the same thing is one
    /// control and a guess. "No default" is the sentinel instead.
    /// </remarks>
    internal ObservableCollection<AuthenticationChoice> GroupEditorAuthenticationChoices { get; } = [];

    [ObservableProperty]
    private AuthenticationChoice? groupEditorSelectedAuthentication;

    /// <summary>The group being renamed, or null when the box would create one.</summary>
    [ObservableProperty]
    private Guid? editingGroupId;

    [ObservableProperty]
    private SshKeyRowViewModel? selectedKey;

    [ObservableProperty]
    private CredentialRowViewModel? selectedCredential;

    [ObservableProperty]
    private ObjectStoreRowViewModel? selectedObjectStore;

    [ObservableProperty]
    private TagRowViewModel? selectedTag;

    // ---- The tag editor ----
    // A fifth set, and the smallest by a long way: a tag is a name. It is still its own pair rather than
    // sharing the group editor's box, for the reason the other four are separate — a half-typed tag
    // appearing inside a group's name field is the kind of thing that only shows up in a bug report.

    [ObservableProperty]
    private bool isEditingTag;

    [ObservableProperty]
    private string tagEditorLabel = string.Empty;

    /// <summary>The tag being edited, or null when creating.</summary>
    private Guid? editingTagId;

    /// <summary>Which vault the tag editor will write to.</summary>
    /// <inheritdoc cref="editingHostVaultId" path="/remarks" />
    private Guid editingTagVaultId;

    [ObservableProperty]
    private KnownHostRowViewModel? selectedKnownHost;

    /// <summary>
    /// What the sidebar's filter box holds.
    /// </summary>
    /// <remarks>
    /// Matched against the label, the address and the notes, case-insensitively, because those are the three
    /// things a person remembers a machine by. It narrows <see cref="HostGroups"/> only: the selection, the
    /// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
    /// unusable — only unlisted.
    /// </remarks>
    [ObservableProperty]
    private string hostFilter = string.Empty;

    [ObservableProperty]
    private string status = string.Empty;

    [ObservableProperty]
    private int pendingChanges;

    /// <summary>
    /// Whether the last synchronisation attempt failed to reach the server.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Holding a connection object is not the same as being able to reach anything, and this is the
    /// difference. <c>IVaultServer</c> is obtained once at sign-in and never dropped, so a laptop whose lid
    /// has been shut all afternoon still has one — and the background pass swallows its socket errors on
    /// purpose, which means nothing else would ever notice.
    /// </para>
    /// <para>
    /// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has
    /// not reached the server since lunch is exactly the sort of thing this project writes down instead of
    /// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable
    /// machine is the ordinary state of a laptop nobody has changed anything on.
    /// </para>
    /// <para>
    /// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the
    /// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic
    /// would flash UNREACHABLE at every launch by somebody who is not.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private bool lastSyncFailed;

    [ObservableProperty]
    private int unreadableItems;

    [ObservableProperty]
    private bool isBusy;

    // ---- Which kind of item is showing ----

    /// <remarks>
    /// Settable, and the markup deliberately does not bind a selector's selection to it. A selection binding
    /// would move before <see cref="ShowSection" /> could refuse, leaving a selector highlighting a section
    /// the column is not showing; two plain buttons and a command carry no state of their own and cannot
    /// disagree with this. Tests set it directly, which is the same thing the command does once it has
    /// decided.
    /// </remarks>
    [ObservableProperty]
    private VaultSection section;

    /// <summary>Whether every kind is showing at once.</summary>
    internal bool ShowsAll => Section is VaultSection.All;

    /// <inheritdoc cref="ShowsAll" />
    internal bool ShowsKeys => Section is VaultSection.Keys;

    /// <inheritdoc cref="ShowsAll" />
    internal bool ShowsCredentials => Section is VaultSection.Credentials;

    /// <inheritdoc cref="ShowsAll" />
    internal bool ShowsBuckets => Section is VaultSection.Buckets;

    /// <inheritdoc cref="ShowsAll" />
    internal bool ShowsTags => Section is VaultSection.Tags;

    /// <summary>
    /// The rows the vault table is showing, for whichever category is selected.
    /// </summary>
    /// <remarks>
    /// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from
    /// storage — so it costs one pass over what is already decrypted in memory and can never disagree with
    /// the lists the editors act on.
    /// </remarks>
    internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];

    /// <summary>
    /// The selected row of the vault table.
    /// </summary>
    /// <remarks>
    /// Setting it sets the matching typed selection, which is what every editor and every delete command
    /// reads. The typed selections stay the state; this is the way the table names one of them.
    /// </remarks>
    [ObservableProperty]
    private VaultItemRowViewModel? selectedVaultItem;

    /// <summary>What the vault screen's header calls the category showing.</summary>
    internal string SectionTitle => Section switch
    {
        VaultSection.Keys => "SSH KEYS",
        VaultSection.Credentials => "PASSWORDS",
        VaultSection.Buckets => "BUCKETS",
        VaultSection.Tags => "TAGS",
        _ => "ALL ITEMS",
    };

    /// <summary>
    /// What the header says about the category, under its name.
    /// </summary>
    /// <remarks>
    /// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this
    /// user's — so the second half is what this build can actually count instead: how many of the ones on
    /// screen this machine has not managed to push yet.
    /// <para>
    /// Counted over the rows showing rather than over the whole outbox, which is what this said first and
    /// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads
    /// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen.
    /// </para>
    /// </remarks>
    internal string SectionSummary
    {
        get
        {
            var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items";
            var waiting = VaultItems.Count(row => row.HasUnsyncedChanges);

            return waiting == 0 ? items : $"{items} · {waiting} not synced";
        }
    }

    /// <summary>Everything on this screen, which is the keychain less the hosts and the pins.</summary>
    /// <remarks>
    /// Both of those have screens of their own now. Counting a pin here would put a number on the ALL
    /// category that the ALL category does not list.
    /// </remarks>
    /// <summary>What the ALL chip counts, which is everything ALL actually shows.</summary>
    /// <remarks>
    /// Buckets were already missing from this before tags were, so the number under a chip labelled ALL has
    /// been smaller than the list it opens for as long as there have been four kinds. Fixed here rather
    /// than left consistent: a count that disagrees with the rows beneath it is worse than no count, and
    /// adding a fifth kind to the same expression would have widened the gap rather than caused it.
    /// </remarks>
    internal int TotalItemCount =>
        Keys.Count + Credentials.Count + ObjectStores.Count + Tags.Count;

    internal bool HasVaultItems => VaultItems.Count > 0;

    internal bool HasSelectedVaultItem => SelectedVaultItem is not null;

    /// <summary>Whether the selected row is one with an editor behind it.</summary>
    internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
        VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;

    /// <summary>Whether the selected row is an SSH key, which is the only kind with a public half to copy.</summary>
    internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;

    /// <summary>What the detail pane calls the block under the chips.</summary>
    internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
    {
        VaultItemKind.Credential => "ACCOUNT",
        _ => "WHAT IS STORED",
    };

    internal bool HasUnreadableItems => UnreadableItems > 0;

    /// <remarks>
    /// A sentence rather than a number, because the number alone reads as a count of something you have
    /// rather than of something you cannot open — and what to do about it is not guessable.
    /// </remarks>
    internal string UnreadableSummary => UnreadableItems == 1
        ? "1 item will not decrypt"
        : $"{UnreadableItems} items will not decrypt";

    /// <summary>What an empty category says instead of showing an empty grid.</summary>
    internal string EmptySectionMessage => Section switch
    {
        VaultSection.Keys =>
            "No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
        VaultSection.Credentials =>
            "No stored passwords yet. Add one to stop typing the same password into every connection.",
        VaultSection.Buckets =>
            "No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.",
        VaultSection.Tags =>
            "No tags yet. Add one here, or from a host's editor, and it becomes a chip you can put on "
            + "twenty machines and rename once.",
        _ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
    };

    // ---- The editor ----

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
    [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
    [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
    [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
    private bool isEditing;

    /// <summary>
    /// Whether the group editor is open as a surface of its own.
    /// </summary>
    /// <remarks>
    /// <para>
    /// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card
    /// that replaces the list — and "is the card showing" is a different question from "which group is being
    /// edited", because adding one has no id.
    /// </para>
    /// <para>
    /// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
    /// always on screen, which is why <see cref="EditingGroupId"/> was enough there. The hosts screen has no
    /// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by
    /// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
    /// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
    /// them.
    /// </para>
    /// <para>
    /// Held here rather than on the phone's own control so that the two heads cannot disagree about
    /// whether an editor is open. The back gesture and the floating button both read it.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
    [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
    [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
    [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
    private bool isEditingGroup;

    /// <summary>
    /// Whether the hosts screen's right-hand drawer is open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The drawer is where everything that is about <em>one</em> thing lives: what a host is, the host
    /// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way
    /// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size.
    /// </para>
    /// <para>
    /// It stays open while a host deletion is in question, because the question is asked in the drawer and a
    /// deletion does not clear the selection. There is no separate term for that here: a pending deletion
    /// always has a selected host behind it.
    /// </para>
    /// <para>
    /// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
    /// rather than a preference — see <c>MainWindow.axaml</c>. Nothing on this screen may be laid over the
    /// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day
    /// somebody moved the grid.
    /// </para>
    /// </remarks>
    internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;

    /// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
    internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null;

    /// <summary>
    /// Whether the add sheet is showing over the host list.
    /// </summary>
    /// <remarks>
    /// The phone's answer to a <c>+</c> that has two things to offer. It is a separate flag from the two
    /// editors because it sits <em>before</em> either of them: the sheet asks which kind, and choosing
    /// closes the sheet and opens that kind's editor. See <see cref="OpenAddSheet"/>.
    /// </remarks>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
    [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
    private bool isAddSheetOpen;

    /// <summary>
    /// Whether anything the host screen can put over its list is showing.
    /// </summary>
    /// <remarks>
    /// One property rather than three tests at each call site, and it exists because two controls need
    /// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
    /// while any of them is up — a button that opens an editor on top of an open editor is a button that
    /// does nothing — and the back gesture closes them before it considers leaving the screen.
    /// </remarks>
    internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;

    /// <summary>
    /// Whether the phone's connect bar has anything to be about.
    /// </summary>
    /// <remarks>
    /// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one
    /// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing
    /// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host
    /// editor, for the very record being typed into.
    /// </remarks>
    internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;

    [ObservableProperty]
    private string editorLabel = string.Empty;

    [ObservableProperty]
    private string editorHostname = string.Empty;

    /// <summary>
    /// The port box, empty when the host is to take its group's.
    /// </summary>
    /// <remarks>
    /// Nullable rather than defaulting to 22, which is the difference between a form that states a port and
    /// one that leaves it to the group. An empty box shows <see cref="EditorPortPlaceholder"/>, so the form
    /// says what leaving it blank will get you rather than making the user guess — and a new host under a
    /// group that says 2222 is created wanting 2222, without anybody typing it.
    /// </remarks>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
    private int? editorPort;

    [ObservableProperty]
    private string editorUsername = string.Empty;

    [ObservableProperty]
    private string editorNotes = string.Empty;

    [ObservableProperty]
    private bool editorRelayEnabled;

    /// <summary>
    /// What the authentication picker offers: a typed password, then every key, then every credential.
    /// </summary>
    /// <remarks>
    /// Rebuilt when the editor opens rather than kept in step with the two lists. A background sync could pull
    /// a new key while a host is being edited, and having the picker's contents change under the user mid-edit
    /// is worse than the list being a minute stale — only one editor may be open at a time, so the only way to
    /// add a key or a credential is to close this one anyway.
    /// </remarks>
    internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];

    [ObservableProperty]
    private AuthenticationChoice? editorSelectedAuthentication;

    /// <summary>What the group picker offers: "no group", then every group.</summary>
    /// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
    internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
    [NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
    private GroupChoice? editorSelectedGroup;

    /// <summary>
    /// Keeps the authentication picker in step with whether there is a group to inherit from.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Filing a host into a group must not pin it to a typed password, and without this it did.</b> An
    /// ungrouped host is offered no "Inherit from group" entry — with nothing above it the two entries would
    /// behave identically — so its picker sits on "Password (ask each time)". Choose a group and save, and
    /// that selection would be written as <see cref="HostSecret.AsksForPassword"/>: the host would be pinned
    /// to a prompt it never asked for, by a user who was only filing it, and the group's key would never
    /// reach it.
    /// </para>
    /// <para>
    /// The selection moves to "Inherit from group" rather than staying, because for an ungrouped host the
    /// two are the same thing and the stored value was "nothing stated". Reading a deliberate refusal into
    /// a choice the user could not have made differently would be inventing an intent; inheriting is what
    /// the record already said.
    /// </para>
    /// </remarks>
    partial void OnEditorSelectedGroupChanged(GroupChoice? value)
    {
        var grouped = value?.EntityId is not null;

        if (grouped == EditorAuthenticationChoices.Contains(AuthenticationChoice.Inherited))
        {
            return;
        }

        var selected = EditorSelectedAuthentication;

        BuildAuthenticationChoices(
            Bound(AuthenticationKind.SshKey),
            Bound(AuthenticationKind.Credential),
            asksForPassword: !grouped && selected?.Kind == AuthenticationKind.Typed,
            grouped);
    }

    /// <summary>What an empty port box will dial.</summary>
    /// <remarks>
    /// <para>
    /// Recomputed when the group picker moves, which is the whole point of it being a placeholder rather
    /// than a pre-filled value: filing a host into a group is supposed to visibly change what leaving the
    /// box empty means. A pre-filled 2222 would have been indistinguishable from a port the user typed, and
    /// saving it would have pinned it.
    /// </para>
    /// <para>
    /// Reads the group chain from the picker's current selection rather than from the host being edited,
    /// because the two differ for exactly as long as the editor is open and unsaved — which is when this is
    /// read.
    /// </para>
    /// </remarks>
    internal string EditorPortPlaceholder =>
        InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture))
        ?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture);

    /// <summary>What an empty username box will log in as, or a prompt when nothing supplies one.</summary>
    /// <inheritdoc cref="EditorPortPlaceholder" path="/remarks" />
    internal string EditorUsernamePlaceholder =>
        InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username";

    /// <remarks>
    /// Walks through <see cref="HostInheritance.Chain"/> rather than looking only at the selected group, so
    /// a placeholder shows the value that will actually be used — which may come from three levels up — and
    /// so that a cycle assembled elsewhere cannot hang the editor either.
    /// </remarks>
    private string? InheritedFromEditorGroup(Func<HostGroupSecret, string?> read) =>
        HostInheritance
            .Chain(EditorSelectedGroup?.EntityId, groupsById)
            .Select(entry => read(entry.Group))
            .FirstOrDefault(value => value is not null);

    /// <summary>
    /// The tags the host being edited wears, as the picker leaves them.
    /// </summary>
    /// <remarks>
    /// The authority while an editor is open, not <see cref="EditorTagChoices"/>: the chips are a
    /// projection of this and are rebuilt from it, so a tag the vault no longer offers — deleted on another
    /// machine between the editor opening and Save — is still carried through rather than dropped by an
    /// edit that was about something else.
    /// </remarks>
    private TagSet editorTagIds = TagSet.Empty;

    /// <summary>
    /// Every tag in the vault, as a chip, with whether the host being edited wears it.
    /// </summary>
    /// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
    internal ObservableCollection<TagChoice> EditorTagChoices { get; } = [];

    /// <summary>Whether there is any tag to offer, which is what draws the picker at all.</summary>
    /// <remarks>
    /// A vault with no tags shows the new-tag box and nothing else. An empty row of chips with a heading
    /// over it would be a control that looks broken rather than one that has nothing to say.
    /// </remarks>
    internal bool HasTagChoices => EditorTagChoices.Count > 0;

    /// <summary>The name in the editor's "new tag" box.</summary>
    [ObservableProperty]
    private string editorNewTag = string.Empty;

    /// <summary>
    /// Puts a tag on the host being edited, or takes it off.
    /// </summary>
    /// <remarks>
    /// Applies to the editor's own set rather than saving anything. The host is written by
    /// <see cref="BuildHost"/> like every other field, so cancelling an edit drops the tagging with the
    /// rest of it — which is what a user who pressed CANCEL asked for.
    /// </remarks>
    [RelayCommand]
    private void ToggleEditorTag(TagChoice? choice)
    {
        if (choice is null)
        {
            return;
        }

        editorTagIds = choice.IsWorn
            ? TagSet.Create(editorTagIds.Where(id => id != choice.EntityId))
            : TagSet.Create([.. editorTagIds, choice.EntityId]);

        BuildTagChoices();
    }

    /// <summary>
    /// Creates a tag from the editor's box and puts it on the host being edited.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The moment a tag is wanted is the moment somebody is tagging a host and finds it does not exist yet,
    /// so this is where creating one belongs. The keychain screen has the list for renaming and deleting;
    /// making a user go there first, come back, and find their half-typed host gone would be the wrong way
    /// round.
    /// </para>
    /// <para>
    /// <b>It writes to the vault immediately, unlike everything else in this editor.</b> A tag is a shared
    /// item with an id, and a host can only name an id that exists — so there is nothing to defer. The
    /// consequence is honest rather than hidden: cancelling the host edit leaves the tag behind, because
    /// the tag was never part of the host.
    /// </para>
    /// <para>
    /// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable
    /// — see <see cref="TagSecret.TryValidate"/> for why refusing the second would be worse — but creating
    /// one by hand, from a box, beside a chip of the same name is a slip rather than an intention.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task AddEditorTagAsync(CancellationToken cancellationToken)
    {
        var label = EditorNewTag.Trim();

        if (label.Length == 0)
        {
            return;
        }

        // Ordinal-ignore-case rather than the current culture's. A tag name is a filter token people type
        // and re-type — "PCI" and "pci" are one tag by anybody's reading — and culture-aware casing would
        // make whether they are the same depend on the phone's locale, which is not a property of the
        // keychain the two machines share.
        if (Tags.FirstOrDefault(
            row => string.Equals(row.Label, label, StringComparison.OrdinalIgnoreCase)) is { } existing)
        {
            editorTagIds = TagSet.Create([.. editorTagIds, existing.EntityId]);
            EditorNewTag = string.Empty;
            BuildTagChoices();
            Status = $"'{existing.Label}' is already in this keychain, so it was used rather than repeated.";
            return;
        }

        var tag = new TagSecret { Label = label };

        if (!tag.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                // The active vault, not the host's. The picker lists the active vault's tags — exactly as
                // the group picker lists the active vault's groups — so creating one anywhere else would
                // put a chip on the host that the editor beside it could not show. A host in a team's vault
                // can therefore end up naming a personal tag, which is the same cross-vault reference a
                // group already allows and is recorded with it in docs/design-import-gaps.md.
                var entityId = await session.Tags
                    .CreateAsync(session.ActiveVaultId, tag, cancellationToken)
                    .ConfigureAwait(true);

                editorTagIds = TagSet.Create([.. editorTagIds, entityId]);
                EditorNewTag = string.Empty;

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                BuildTagChoices();

                Status = $"Added the tag '{tag.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Refills the editor's chips from the vault's tags and the set being edited.</summary>
    /// <remarks>
    /// Rebuilt wholesale rather than mutated, because <see cref="TagChoice"/> is a record and its
    /// <c>IsWorn</c> is part of its value. One toggle therefore replaces the collection, which for a list of
    /// chips is cheaper than raising change notification on each of them.
    /// </remarks>
    private void BuildTagChoices()
    {
        EditorTagChoices.Clear();

        foreach (var tag in Tags)
        {
            EditorTagChoices.Add(
                new TagChoice(tag.EntityId, tag.Label, editorTagIds.Contains(tag.EntityId)));
        }

        OnPropertyChanged(nameof(HasTagChoices));
    }

    /// <summary>The item being edited, or null when creating.</summary>
    private Guid? editingEntityId;

    /// <summary>
    /// Which vault the editor will write to.
    /// </summary>
    /// <remarks>
    /// Captured when the editor opens rather than read at save time, and there are two different reasons
    /// for that depending on which way the editor was opened. Editing an existing item, it is the vault
    /// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
    /// target picker said <em>at that moment</em>, so changing the picker afterwards cannot silently move
    /// a half-typed host into a team's vault.
    /// </remarks>
    private Guid editingHostVaultId;

    /// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
    private Guid editingKeyVaultId;

    /// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
    private Guid editingCredentialVaultId;

    /// <summary>
    /// Whether the editor is showing a host that could have a pinned key to forget.
    /// </summary>
    /// <remarks>
    /// Read by the editor to hide the button while a host is being created, where there is nothing to
    /// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the
    /// known-host store for a button's visibility, and the command already says plainly when there was
    /// nothing to forget.
    /// </remarks>
    internal bool CanForgetHostKey => IsEditing && editingEntityId is not null;

    // ---- The key editor ----
    // A second set of editor state rather than a shared one. The two editors hold unrelated fields, and
    // sharing them would mean a half-typed host reappearing inside a key editor.

    [ObservableProperty]
    private bool isEditingKey;

    [ObservableProperty]
    private string keyEditorLabel = string.Empty;

    /// <remarks>
    /// Bound to a text box the user pastes a private key into, so this holds key material for as long as
    /// the editor is open, and <see cref="CancelKeyEdit" /> clears it. Neither that nor anything else here
    /// can wipe it — see <c>SshKeySecret</c>, which explains why a .NET string is the honest choice for
    /// this and what it does not buy.
    /// </remarks>
    [ObservableProperty]
    private string keyEditorPrivateKey = string.Empty;

    [ObservableProperty]
    private string keyEditorPassphrase = string.Empty;

    [ObservableProperty]
    private string keyEditorPublicKey = string.Empty;

    [ObservableProperty]
    private string keyEditorNotes = string.Empty;

    /// <summary>The key being edited, or null when creating.</summary>
    private Guid? editingKeyId;

    // ---- Generating a key ----

    /// <summary>
    /// Whether the small form in front of generating a key is showing.
    /// </summary>
    /// <remarks>
    /// A step of its own rather than two more boxes in the key editor, because the two are opposite
    /// directions: the editor is where a key that already exists is pasted in, and this makes one that does
    /// not exist yet. What it produces lands in that editor unsaved, so there is still exactly one thing in
    /// this application that writes a key, and it is still SAVE.
    /// </remarks>
    [ObservableProperty]
    private bool isGeneratingKey;

    /// <summary>
    /// What the generated key is called, and the comment written into it.
    /// </summary>
    /// <remarks>
    /// One field for both. The comment is the only thing in a host's <c>authorized_keys</c> that will ever
    /// say where a key came from, and a key whose name here and comment there disagree is one nobody can
    /// match up months later when they are deciding which line to delete.
    /// </remarks>
    [ObservableProperty]
    private string generateComment = string.Empty;

    [ObservableProperty]
    private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519;

    /// <inheritdoc cref="ShowsAll" />
    internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519;

    /// <inheritdoc cref="ShowsAll" />
    internal bool GeneratesRsa => GenerateAlgorithm is SshKeyAlgorithm.Rsa4096;

    // ---- The credential editor ----
    // A third set, on the same reasoning as the second: three editors holding unrelated fields, and sharing
    // them would mean a half-typed key reappearing inside a credential.

    [ObservableProperty]
    private bool isEditingCredential;

    [ObservableProperty]
    private string credentialEditorLabel = string.Empty;

    /// <remarks>
    /// Optional, and what makes a credential worth being its own item: one account on twenty machines is
    /// rotated in one place. Blank means "use each host's own username" — see <c>CredentialSecret.Username</c>,
    /// which normalises the two spellings of that to one.
    /// </remarks>
    [ObservableProperty]
    private string credentialEditorUsername = string.Empty;

    /// <remarks>
    /// Holds a password for as long as the editor is open, and <see cref="CancelCredentialEdit" /> clears it —
    /// the same bargain, and the same limits, as the private key box. See <c>CredentialSecret</c>.
    /// </remarks>
    [ObservableProperty]
    private string credentialEditorPassword = string.Empty;

    [ObservableProperty]
    private string credentialEditorNotes = string.Empty;

    /// <summary>The credential being edited, or null when creating.</summary>
    private Guid? editingCredentialId;

    // ---- The bucket editor ----
    // A fourth set, on the same reasoning as the third.

    [ObservableProperty]
    private bool isEditingObjectStore;

    [ObservableProperty]
    private string bucketEditorLabel = string.Empty;

    [ObservableProperty]
    private string bucketEditorBucket = string.Empty;

    [ObservableProperty]
    private string bucketEditorAccessKeyId = string.Empty;

    /// <remarks>
    /// Holds a secret access key for as long as the editor is open, and cancelling clears it — the same
    /// bargain, and the same limits, as the password box. A secret access key is a password.
    /// </remarks>
    [ObservableProperty]
    private string bucketEditorSecretAccessKey = string.Empty;

    [ObservableProperty]
    private string bucketEditorRegion = string.Empty;

    /// <remarks>
    /// Blank means Amazon and the region resolves the host. Anything else is a full URL, which is what makes
    /// this work against a MinIO on somebody's own network.
    /// </remarks>
    [ObservableProperty]
    private string bucketEditorEndpoint = string.Empty;

    /// <remarks>
    /// Defaulted on for a new bucket, which is the opposite of the AWS default and the right guess here:
    /// somebody adding a bucket with a custom endpoint is nearly always pointing at a self-hosted service,
    /// and those have no wildcard DNS. Somebody adding an AWS bucket leaves the endpoint blank, and
    /// <see cref="NewObjectStore"/> turns it off for them.
    /// </remarks>
    [ObservableProperty]
    private bool bucketEditorUsePathStyle;

    [ObservableProperty]
    private string bucketEditorNotes = string.Empty;

    /// <summary>The bucket being edited, or null when creating.</summary>
    private Guid? editingObjectStoreId;

    // ---- Deleting ----

    /// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
    /// <remarks>
    /// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
    /// looking at two cards, and this application only ever has one selected item per screen to aim a
    /// question at.
    /// </remarks>
    [ObservableProperty]
    private DeletionRequest? pendingDeletion;

    internal bool IsConfirmingDeletion => PendingDeletion is not null;

    /// <summary>Whether the sidebar's row of host buttons is showing.</summary>
    /// <remarks>
    /// Its own property because the markup cannot express <c>!IsEditing &amp;&amp; !IsConfirmingDeletion</c>,
    /// and because both halves are the same rule: the question about deleting a host takes the place of the
    /// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
    /// on screen.
    /// </remarks>
    internal bool ShowsHostActions => !IsEditing && !IsConfirmingHostDeletion;

    /// <summary>
    /// Whether the question on screen is the one about deleting a host.
    /// </summary>
    /// <remarks>
    /// The sidebar and the group panel are on the same screen, and there is one pending deletion between
    /// them, so each has to ask whether the question is <em>its own</em> — otherwise deleting a group draws
    /// the group's question inside the host sidebar as well, in a place its buttons never were.
    /// </remarks>
    internal bool IsConfirmingHostDeletion => PendingDeletion?.Target is DeletionTarget.Host;

    /// <inheritdoc cref="IsConfirmingHostDeletion" />
    internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;

    /// <summary>Whether the group panel's buttons are showing.</summary>
    internal bool ShowsGroupActions => !IsConfirmingGroupDeletion;

    /// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
    internal bool HasGroups => Groups.Count > 0;

    /// <summary>What the group panel's save button says.</summary>
    /// <remarks>
    /// "SAVE" rather than the "RENAME" it said while a group was only a name. A button that offers to
    /// rename, pressed after somebody has changed the default port beside it, describes one of the four
    /// things it is about to do.
    /// </remarks>
    internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE";

    /// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
    /// <inheritdoc cref="ShowsHostActions" />
    internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;

    // ---- Connecting ----

    /// <remarks>
    /// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
    /// only reached by a host bound to nothing. It stays because not every password is worth storing — a
    /// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
    /// — and because a credential has to be created before it can be bound, which means the first connection
    /// to a new host happens through this box.
    /// </remarks>
    [ObservableProperty]
    private string connectPassword = string.Empty;

    /// <summary>
    /// Whether a password typed here should be kept, so this host stops asking for it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
    /// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
    /// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
    /// means typing the secret into a second screen while the first one already has it.
    /// </para>
    /// <para>
    /// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
    /// every password belongs in a synchronised vault; remembering silently would move each of them there and
    /// tell nobody. It also only takes effect once the handshake has succeeded — see
    /// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
    /// the one not worth keeping.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private bool remembersConnectPassword;

    /// <summary>
    /// Whether the selected host will want something typed into the password box.
    /// </summary>
    /// <remarks>
    /// <para>
    /// True with nothing selected, which is deliberate: the box is the resting state of that corner of the
    /// window, and an empty terminal column with no password box in it reads as a column that is still
    /// loading.
    /// </para>
    /// <para>
    /// The resolved binding, not the host's own two ids. A host that names neither used to mean "type one",
    /// and now means "take the group's" — so reading the ids directly would put a password box in front of
    /// every host under a group that binds a key, and the box would do nothing.
    /// </para>
    /// </remarks>
    internal bool SelectedHostAsksForAPassword =>
        SelectedHost is null
        || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;

    /// <summary>
    /// What the terminal column says in place of the password box, or nothing when the box is showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs
    /// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two
    /// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this
    /// said nothing, and a key cannot.
    /// </para>
    /// <para>
    /// <b>An inherited binding says so.</b> This is the one place with room for the sentence, and it is worth
    /// spending: a user looking at a host that says nothing about keys, being told it authenticates with one,
    /// would go looking in the wrong editor. Naming the group points at the record that can actually be
    /// changed.
    /// </para>
    /// </remarks>
    internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
    {
        { Kind: ResolvedBindingKind.Credential } binding =>
            $"This host uses a password stored in your keychain{From(binding)}.",
        { Kind: ResolvedBindingKind.SshKey } binding =>
            $"This host authenticates with an SSH key{From(binding)}.",
        _ => string.Empty,
    };

    /// <remarks>
    /// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
    /// search. A group that has since been deleted leaves the binding dangling, which the connect path
    /// reports on its own — this only has to avoid claiming a name it cannot read.
    /// </remarks>
    private string From(ResolvedBinding binding) =>
        binding.FromGroupId is { } groupId && groupsById.TryGetValue(groupId, out var group)
            ? $", from the group {group.Label}"
            : string.Empty;

    [ObservableProperty]
    private HostKeyPresentation? pendingHostKey;

    [ObservableProperty]
    private string? hostKeyMismatch;

    /// <summary>
    /// Raised once a terminal session is open and its renderer has it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// An event rather than a property because handing the terminal the keyboard is something that
    /// happens, not something that is true: connecting a second host while one is already open has to
    /// move focus again, and no state change describes that. Raised on the UI thread — every await on
    /// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
    /// controls directly.
    /// </para>
    /// <para>
    /// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The
    /// vault is the only thing that knows what this session is <em>of</em> — a host's name is a decrypted
    /// item — so the naming happens here and the tab list happens there.
    /// </para>
    /// </remarks>
    internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;

    /// <summary>
    /// Raised the moment a connection is asked for, before anything has been dialled.
    /// </summary>
    /// <remarks>
    /// The other half of <see cref="SessionOpened"/>, and the reason connecting no longer makes the window
    /// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the
    /// handshake is still happening, and every other screen stays usable. Exactly one of
    /// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
    /// <c>AttemptId</c>.
    /// </remarks>
    internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;

    /// <summary>Raised when a connection this vault announced does not become a session.</summary>
    /// <inheritdoc cref="ConnectionStarting" path="/remarks" />
    internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;

    internal bool HasPendingHostKey => PendingHostKey is not null;

    internal bool HasHostKeyMismatch => HostKeyMismatch is not null;

    internal bool HasConflicts => Conflicts.Count > 0;

    /// <summary>Reads the vault into the list and says what is in it.</summary>
    internal async Task LoadAsync(CancellationToken cancellationToken)
    {
        await ReloadAsync(cancellationToken).ConfigureAwait(true);

        // Built from whatever is there rather than enumerated per combination. Two item types were four cases;
        // three would be eight, and the fourth kind the selector will eventually grow — pinned host keys —
        // would be sixteen.
        var counted = new List<string>(3);

        if (Hosts.Count > 0)
        {
            counted.Add($"{Hosts.Count} host(s)");
        }

        if (Keys.Count > 0)
        {
            counted.Add($"{Keys.Count} key(s)");
        }

        if (Credentials.Count > 0)
        {
            counted.Add($"{Credentials.Count} credential(s)");
        }

        var contents = string.Join(", ", counted);

        // "No hosts yet" survives as its own case, because it is the one nudge this line gives: a vault holding
        // keys and credentials and no hosts is set up but unused, and "2 key(s) in Personal." would read as
        // though everything were in order.
        Status = (Hosts.Count, counted.Count) switch
        {
            (0, 0) => "No hosts yet. Add one.",
            (0, _) => $"No hosts yet, and {contents} in {VaultName}.",
            _ => $"{contents} in {VaultName}.",
        };
    }

    /// <summary>
    /// Reads the vault into the list, silently.
    /// </summary>
    /// <remarks>
    /// Separate from <see cref="LoadAsync" /> because every caller except the first load has something
    /// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing
    /// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made
    /// "the background pass is quiet" false on the one path that mattered.
    /// </remarks>
    private async Task ReloadAsync(CancellationToken cancellationToken)
    {
        // First, because the four lists below are read across the same set and a vault admitted by the
        // last refresh should appear in the picker on the same pass its items do.
        RebuildTargetVaults();

        // Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
        // gets counted under them — so the host reload is the pass that can put both together. Tags are read
        // in the same place and for the same reason: a host row draws a chip per tag, and it can only draw
        // the name if the tag was read first.
        var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);

        unreadable += await ReloadTagsAsync(cancellationToken).ConfigureAwait(true);

        unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);

        // After the hosts, because a tag row says how many wear it.
        RebuildTags();

        unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
        unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
        unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
        unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);

        // Last, because it reads the host list to work out which pins nothing dials any more.
        unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true);

        UnreadableItems = unreadable;
        PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);

        // After all four lists, because the table is a projection of three of them.
        RebuildVaultItems();

        await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
    /// <remarks>
    /// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
    /// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
    /// vault rather than to nothing, so the next Save still has somewhere to go.
    /// </remarks>
    private void RebuildTargetVaults()
    {
        var selectedId = TargetVaultId;

        TargetVaults.Clear();

        foreach (var vault in session.ReadableVaults
            .Where(vault => vault.CanWrite)
            .OrderByDescending(vault => vault.IsPersonal)
            .ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
        {
            TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
        }

        SelectedTargetVault =
            TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
            ?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
            ?? TargetVaults.FirstOrDefault();

        OnPropertyChanged(nameof(HasVaultChoice));
    }

    /// <returns>How many hosts would not decrypt.</returns>
    private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
    {
        var selectedId = SelectedHost?.EntityId;
        var unreadable = 0;
        var rows = new List<HostRowViewModel>();

        // Every vault this session holds a key for, not only the one new items are filed into. A team
        // vault whose hosts never reached this list would make sharing look as though it had not worked.
        var readable = session.ReadableVaults.ToList();
        var several = readable.Count > 1;

        foreach (var vault in readable)
        {
            var listing = await session.Hosts
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            // Resolved here, which is why ReloadGroupsAsync and ReloadTagsAsync both run before this: a host
            // resolved against a stale group list would show one port and dial another, and one resolved
            // against a stale tag list would draw a chip that has been renamed.
            rows.AddRange(listing.Items.Select(
                item => new HostRowViewModel(
                    item, Resolve(item.Secret), LabelsFor(item.Secret.TagIds), vault.VaultId, vault.Name)
                {
                    // Only when there is something to tell apart. A badge on every row of a
                    // single-vault list is noise that says the same thing on all of them.
                    VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
                }));
        }

        Hosts.Clear();

        // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
        // hold a host with the same label and both are shown: which vault it is in is what tells them
        // apart, which is why the row carries the name rather than the list deduplicating.
        foreach (var host in rows
            .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
            .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
            .ThenBy(row => row.Label, StringComparer.CurrentCulture))
        {
            Hosts.Add(host);
        }

        // Selection survives a reload. Losing it on every sync would move the terminal's target out from
        // under the user.
        SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();

        // Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
        // the group rows.
        RebuildGroups();
        RebuildVisibleHosts();

        return unreadable;
    }

    /// <returns>How many buckets would not decrypt.</returns>
    /// <remarks>
    /// The selection survives a reload and a reload never invents one, as the key and credential lists do and
    /// for the same reason: it is what the delete button aims at.
    /// </remarks>
    private async Task<int> ReloadObjectStoresAsync(CancellationToken cancellationToken)
    {
        var listing = await session.ObjectStores
            .ListAsync(session.ActiveVaultId, cancellationToken)
            .ConfigureAwait(true);

        var selectedId = SelectedObjectStore?.EntityId;

        ObjectStores.Clear();

        foreach (var store in listing.Items
            .OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
        {
            ObjectStores.Add(new ObjectStoreRowViewModel(store));
        }

        SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);

        return listing.Unreadable;
    }

    /// <returns>How many snippets would not decrypt.</returns>
    /// <remarks>
    /// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
    /// list changing the way every other screen does.
    /// </remarks>
    private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
    {
        var listing = await session.Snippets
            .ListAsync(session.ActiveVaultId, cancellationToken)
            .ConfigureAwait(true);

        Snippets.Clear();

        foreach (var snippet in listing.Items
            .OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
        {
            Snippets.Add(new SnippetRowViewModel(snippet));
        }

        return listing.Unreadable;
    }

    /// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
    /// <param name="entityId">The snippet to replace, or null to create one.</param>
    /// <param name="snippet">What to store.</param>
    /// <param name="cancellationToken">Cancellation.</param>
    /// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
    /// <remarks>
    /// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
    /// same immediate push as every other save. The screen decides <em>what</em> a snippet is and nothing
    /// else.
    /// </remarks>
    internal async Task<bool> SaveSnippetAsync(
        Guid? entityId,
        SnippetSecret snippet,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(snippet);

        if (!snippet.TryValidate(out var reason))
        {
            Status = reason;
            return false;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (entityId is { } existing)
                {
                    await session.Snippets
                        .UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    await session.Snippets
                        .CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
                        .ConfigureAwait(true);
                }

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = connection() is null
                    ? $"Saved '{snippet.Label}'. It will sync when you are online."
                    : $"Saved '{snippet.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);

        return true;
    }

    /// <summary>Queues a tombstone for one snippet.</summary>
    internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That snippet is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.Snippets
                    .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
                    .ConfigureAwait(true);

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Deleted '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <returns>How many groups would not decrypt.</returns>
    /// <remarks>
    /// <para>
    /// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row
    /// carries how many hosts name it and the hosts have not been read yet when this runs. See
    /// <see cref="RebuildGroups"/>, which is where the two meet.
    /// </para>
    /// <para>
    /// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
    /// sidebar draws headings from and the group editor renames — is the active vault's alone. The
    /// resolution map is every readable vault's.
    /// </para>
    /// <para>
    /// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
    /// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
    /// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
    /// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
    /// </para>
    /// <para>
    /// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
    /// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
    /// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
    /// began lending a port, a username and a binding, the same omission silently drops all three: that host
    /// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
    /// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
    /// place.
    /// </para>
    /// <para>
    /// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
    /// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
    /// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
    /// </para>
    /// </remarks>
    private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
    {
        var unreadable = 0;
        var resolvable = new Dictionary<Guid, HostGroupSecret>();

        // Emptied before the loop rather than assigned inside it, because the active vault may not be in
        // the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
        // ever writes on a match would leave the last readable vault's groups on screen as though they were
        // still this one's.
        groupItems = [];

        foreach (var vault in session.ReadableVaults)
        {
            var listing = await session.HostGroups
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            foreach (var group in listing.Items)
            {
                resolvable[group.EntityId] = group.Secret;
            }

            if (vault.VaultId == session.ActiveVaultId)
            {
                groupItems =
                    [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
            }
        }

        groupsById = resolvable;

        return unreadable;
    }

    /// <returns>How many tags would not decrypt.</returns>
    /// <remarks>
    /// The same two reads as <see cref="ReloadGroupsAsync"/>, and the same split: the editable list is the
    /// active vault's, the resolution map is every readable vault's. A tag id that does not resolve simply
    /// draws no chip, so the cost of a narrow map here is a host that looks untagged rather than one that
    /// dials the wrong port — cheaper than the group case, and no cheaper to get right.
    /// </remarks>
    private async Task<int> ReloadTagsAsync(CancellationToken cancellationToken)
    {
        var unreadable = 0;
        var resolvable = new Dictionary<Guid, TagSecret>();

        tagItems = [];

        foreach (var vault in session.ReadableVaults)
        {
            var listing = await session.Tags
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            foreach (var tag in listing.Items)
            {
                resolvable[tag.EntityId] = tag.Secret;
            }

            if (vault.VaultId == session.ActiveVaultId)
            {
                tagItems = [.. listing.Items.OrderBy(tag => tag.Secret.Label, StringComparer.CurrentCulture)];
            }
        }

        tagsById = resolvable;

        return unreadable;
    }

    /// <summary>Refills <see cref="Tags"/>, counting the hosts wearing each.</summary>
    /// <remarks>
    /// Counted over <see cref="Hosts"/>, which spans every readable vault, while the rows themselves are
    /// the active vault's. That asymmetry is deliberate and is the same one the delete warning needs: a tag
    /// worn by a teammate's host is still worn, and a count that ignored those would tell somebody a tag
    /// was unused just before they deleted it out from under twenty machines.
    /// </remarks>
    private void RebuildTags()
    {
        var selectedId = SelectedTag?.EntityId;

        Tags.Clear();

        foreach (var tag in tagItems)
        {
            Tags.Add(new TagRowViewModel(
                tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
        }

        SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
    }

    /// <summary>
    /// A host with its group chain applied: the port to dial, the user to log in as, and how to
    /// authenticate.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The one place this view model turns a stored host into a dialled one. A host may leave any of the
    /// three unset and take its group's, so reading <c>host.Port</c> or <c>host.SshKeyId</c> directly
    /// answers "what did the user type into this host" and not "what happens when this is connected" — and
    /// almost everything on screen wants the second question. See <see cref="HostInheritance"/>.
    /// </para>
    /// <para>
    /// Reads <see cref="groupsById"/>, which is refilled by <see cref="ReloadGroupsAsync"/> before the hosts
    /// are read. That ordering is not incidental: a host resolved against a stale group list would show one
    /// port and dial another.
    /// </para>
    /// </remarks>
    internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);

    /// <summary>
    /// The names behind a host's tag ids, sorted for display, skipping any that do not resolve.
    /// </summary>
    /// <remarks>
    /// Sorted by name rather than kept in the set's own order. <see cref="TagSet"/> sorts by id, which is a
    /// UUIDv7 and therefore by when the tag was created — an order that means nothing on screen and changes
    /// what a row looks like depending on which machine made which tag first.
    /// </remarks>
    private IReadOnlyList<string> LabelsFor(TagSet tagIds) =>
    [
        .. tagIds
            .Where(tagsById.ContainsKey)
            .Select(id => tagsById[id].Label)
            .OrderBy(label => label, StringComparer.CurrentCulture),
    ];

    /// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
    private void RebuildGroups()
    {
        var selectedId = SelectedGroup?.EntityId;
        var filteredId = GroupFilter?.EntityId;

        Groups.Clear();

        foreach (var group in groupItems)
        {
            var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);

            Groups.Add(new HostGroupRowViewModel(group, count));
        }

        // Never defaulted to the first row, as the key and credential lists are not: this selection is what
        // RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
        // chose.
        SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);

        // Re-resolved by id for the reason the selection above is: every row object here is replaced on
        // every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
        // longer in the list — and the card the user could press to clear it would be a different object
        // that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
        // grid comes back to every host rather than to none.
        //
        // This assignment is a new row object whenever there is a filter at all, so it always fires
        // OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
        // wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
        // the same thing — and it is left rather than dodged by writing the backing field, because writing
        // the field would skip SelectedGroup and IsFilteredByGroup with it.
        GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);

        OnPropertyChanged(nameof(HasGroups));
    }

    /// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
    /// <remarks>
    /// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
    /// keeps this method from undoing its own caller. <c>ListBox.SelectedItem</c> is two-way bound to
    /// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> the list reacts to by
    /// nulling its selection, and the binding writes that null straight back — silently, and before this
    /// method's own refill has a chance to matter. <see cref="ReloadHostsAsync"/> restores the selection and
    /// calls this immediately after, which used to mean every reload undid what it had just restored, and
    /// every keystroke in the filter box did the same.
    /// </remarks>
    private void RebuildVisibleHosts()
    {
        var selected = SelectedHost;

        VisibleHosts.Clear();

        foreach (var host in Hosts.Where(Matches))
        {
            VisibleHosts.Add(host);
        }

        RebuildSidebarRows();

        // Restored when it still matches, and explicitly cleared when it does not — rather than left alone
        // and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
        // the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
        // property directly, and a host that is not on screen is not one any of them should act on.
        SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;

        // After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
        // above is a null that has to be overwritten rather than read.
        SelectedSidebarRow = SelectedHost;

        // The grid's empty state. Both of these are computed rather than stored, and neither has a change
        // notification of its own — VisibleHosts raises collection changes, which is not the same event.
        OnPropertyChanged(nameof(HasVisibleHosts));
        OnPropertyChanged(nameof(NoVisibleHostsMessage));
    }

    /// <summary>
    /// Lays the visible hosts out under their group headings.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>No groups means no headings.</b> The sidebar of a vault nobody has filed anything in is the list it
    /// always was, which is what makes this feature cost nothing to ignore.
    /// </para>
    /// <para>
    /// <b>A host whose group has been deleted falls under the ungrouped heading</b> rather than disappearing
    /// or keeping an empty heading of its own. The reference is allowed to dangle — see
    /// <see cref="HostSecret.GroupId"/> for why deleting a group deliberately does not rewrite the hosts in
    /// it — so "the group this names is not here" and "this names no group" have to look the same, because to
    /// the user they are the same thing.
    /// </para>
    /// <para>
    /// An empty group still gets its heading, and a group emptied by the <em>filter</em> does not. The first
    /// is a thing the user made and can file hosts into; the second is an absence of search results, and a
    /// heading with nothing under it would read as a group that had lost its contents.
    /// </para>
    /// </remarks>
    private void RebuildSidebarRows()
    {
        SidebarRows.Clear();

        if (Groups.Count == 0)
        {
            foreach (var host in VisibleHosts)
            {
                SidebarRows.Add(host);
            }

            return;
        }

        var known = Groups.Select(group => group.EntityId).ToHashSet();

        foreach (var group in Groups)
        {
            AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
        }

        AddSection(
            null,
            "UNGROUPED",
            host => host.Host.GroupId is not { } id || !known.Contains(id),
            onlyWhenOccupied: true);

        void AddSection(
            Guid? groupId,
            string label,
            Func<HostRowViewModel, bool> belongs,
            bool onlyWhenOccupied = false)
        {
            var members = VisibleHosts.Where(belongs).ToArray();

            if (onlyWhenOccupied && members.Length == 0)
            {
                return;
            }

            var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);

            SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));

            if (!expanded)
            {
                return;
            }

            foreach (var member in members)
            {
                SidebarRows.Add(member);
            }
        }
    }

    /// <summary>Folds one group's hosts away, or brings them back.</summary>
    /// <remarks>
    /// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
    /// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
    /// forgotten a minute after it was set. The ungrouped heading uses <see cref="Guid.Empty"/>, which is not
    /// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
    /// </remarks>
    [RelayCommand]
    private void ToggleGroup(SidebarGroupHeader? header)
    {
        if (header is null)
        {
            return;
        }

        var key = header.GroupId ?? Guid.Empty;

        if (!collapsedGroups.Remove(key))
        {
            collapsedGroups.Add(key);
        }

        RebuildSidebarRows();
        SelectedSidebarRow = SelectedHost;
    }

    /// <summary>
    /// Files one host under one group, or under none.
    /// </summary>
    /// <remarks>
    /// <para>
    /// What dragging a row onto a heading does, and the only thing in this application that changes a host
    /// without opening the editor. That is the justification for it existing at all: filing thirty imported
    /// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
    /// host that is about arrangement rather than about the machine.
    /// </para>
    /// <para>
    /// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
    /// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
    /// host would be a save the user never asked for, and one they would then be unable to cancel.
    /// </para>
    /// <para>
    /// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
    /// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
    /// </para>
    /// <para>
    /// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
    /// method that takes one cancels the previous execution's token on every invocation, and two drops in
    /// quick succession are two writes rather than one superseding the other. This is one row's one field
    /// and it is over in a moment.
    /// </para>
    /// </remarks>
    /// <param name="request">The host to move, and where to.</param>
    [RelayCommand]
    private async Task MoveHostToGroupAsync(HostGroupMove? request)
    {
        if (request is not { Host: { } row })
        {
            return;
        }

        if (row.IsReadOnly)
        {
            // The same refusal editing makes, and for the same reason: re-encoding an item a newer client
            // wrote would drop the fields this build has no concept of.
            Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
            return;
        }

        if (IsEditing)
        {
            Status = "Finish or cancel the host you are editing first.";
            return;
        }

        Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
            ? wanted
            : null;

        if (row.Host.GroupId == target)
        {
            return;
        }

        var moved = row.Host with { GroupId = target };
        var name = target is null
            ? "no group"
            : Groups.First(group => group.EntityId == target).Label;

        await RunAsync(
            $"Filing {row.Label} under {name}…",
            async () =>
            {
                await session.Hosts
                    .UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
                    .ConfigureAwait(true);

                await ReloadAsync(CancellationToken.None).ConfigureAwait(true);

                // Re-found rather than kept: the reload replaces every row, so the object that was dragged is
                // no longer the one in the list, and leaving the selection pointing at it would light nothing.
                SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);

                Status = target is null
                    ? $"'{row.Label}' is no longer in a group."
                    : $"Filed '{row.Label}' under '{name}'.";
            }).ConfigureAwait(true);

        // Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
        // editor.
        await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
    }

    /// <remarks>
    /// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
    /// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
    /// wrote down about a machine is often the only place its purpose is recorded.
    /// </remarks>
    private bool Matches(HostRowViewModel row)
    {
        // The group cards, and they narrow before the box does — a host outside the chosen group is out
        // whatever was typed. The two are deliberately not one control: the box is what you type when you
        // know the name, and the cards are what you press when you do not.
        if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
        {
            return false;
        }

        var filter = HostFilter.Trim();

        if (filter.Length == 0)
        {
            return true;
        }

        return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes);

        bool Contains(string? value) =>
            value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
    }

    /// <returns>How many keys would not decrypt.</returns>
    /// <remarks>
    /// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
    /// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
    /// that button at a key nobody chose.
    /// </remarks>
    private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
    {
        var selectedId = SelectedKey?.EntityId;
        var unreadable = 0;
        var rows = new List<SshKeyRowViewModel>();

        foreach (var vault in session.ReadableVaults)
        {
            var listing = await session.SshKeys
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            rows.AddRange(listing.Items.Select(
                item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
        }

        Keys.Clear();

        foreach (var key in rows
            .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
            .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
            .ThenBy(row => row.Label, StringComparer.CurrentCulture))
        {
            Keys.Add(key);
        }

        SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);

        return unreadable;
    }

    /// <returns>How many credentials would not decrypt.</returns>
    /// <remarks>
    /// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
    /// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
    /// that fell back to its first row would point the deletion — and the question in front of it — at a
    /// password nobody chose.
    /// </remarks>
    private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
    {
        var selectedId = SelectedCredential?.EntityId;
        var unreadable = 0;
        var rows = new List<CredentialRowViewModel>();

        foreach (var vault in session.ReadableVaults)
        {
            var listing = await session.Credentials
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            rows.AddRange(listing.Items.Select(
                item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
        }

        Credentials.Clear();

        foreach (var credential in rows
            .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
            .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
            .ThenBy(row => row.Label, StringComparer.CurrentCulture))
        {
            Credentials.Add(credential);
        }

        SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);

        return unreadable;
    }

    /// <returns>How many pins would not decrypt.</returns>
    /// <remarks>
    /// Read through the repository rather than through <c>VaultKnownHostStore</c>, which holds a snapshot
    /// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list
    /// has to show duplicates, because a duplicate is one of the things worth seeing.
    /// </remarks>
    private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
    {
        var selectedId = SelectedKnownHost?.EntityId;
        var unreadable = 0;
        var rows = new List<KnownHostRowViewModel>();

        // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
        // hundred scans of the host list on every background sync.
        var dialled = Hosts
            .Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
        // the active vault alone. The difference is deliberate and is stated in the README: a pin in a
        // team vault is something a teammate can write, and letting it answer for a host in somebody's
        // personal vault would let one member suppress another's first-contact prompt. Showing them is
        // safe and is the only way somebody can see what their team has trusted.
        foreach (var vault in session.ReadableVaults)
        {
            var listing = await session.KnownHosts
                .ListAsync(vault.VaultId, cancellationToken)
                .ConfigureAwait(true);

            unreadable += listing.Unreadable;

            rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
                item,
                dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
                vault.VaultId,
                vault.Name)));
        }

        KnownHostPins.Clear();

        foreach (var pin in rows
            .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
            .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
            .ThenBy(row => row.Label, StringComparer.CurrentCulture))
        {
            KnownHostPins.Add(pin);
        }

        SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);

        return unreadable;
    }

    /// <remarks>
    /// Case-insensitively, because a host name is, and <c>KnownHostIdentity</c> keys the store the same way.
    /// A pin written as <c>DB.internal</c> and a host saved as <c>db.internal</c> are the same machine, and a
    /// list that called one of them unused would be inviting somebody to delete trust they rely on.
    /// </remarks>
    private static string Endpoint(string host, int port) =>
        string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");

    /// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
    /// <remarks>
    /// The offline branch is inside <see cref="RunAsync" /> rather than in front of it, because getting
    /// online is now part of what this button does: resuming a remembered sign-in is a network round trip
    /// and belongs under the same busy flag as the pass it leads to.
    /// </remarks>
    [RelayCommand]
    private async Task SyncAsync(CancellationToken cancellationToken)
    {
        await RunAsync(
            "Synchronising…",
            async () =>
            {
                if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
                {
                    LastSyncFailed = true;
                    Status = "Offline. Changes are queued and will be sent as soon as this machine "
                        + "can reach the server again.";
                    return;
                }

                var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);

                // Null means a background pass held the gate. Saying so beats reporting a sync that this
                // press did not perform.
                Status = report is null
                    ? "A synchronisation is already running."
                    : Describe(report);
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Finds a server to sync against, getting this machine online if it is not.
    /// </summary>
    /// <remarks>
    /// The handler is asked even when a connection is already held, which looks redundant and is not: the
    /// shell is the thing that persists the refresh token so a later launch can resume, and identity
    /// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
    /// sign-in current without an event and without this view model knowing what a token is.
    /// </remarks>
    private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
        reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);

    /// <summary>
    /// Starts syncing in the background until the vault is disposed.
    /// </summary>
    /// <remarks>
    /// Explicit rather than started from the constructor, so that a test can drive
    /// <see cref="AutoSyncAsync" /> a pass at a time instead of racing a timer.
    /// </remarks>
    internal void StartAutoSync()
    {
        if (autoSync is not null)
        {
            return;
        }

        autoSync = new CancellationTokenSource();
        autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
    }

    /// <summary>
    /// One background synchronisation pass, which stays out of the way.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Deliberately not routed through <see cref="RunAsync" />. That would raise the busy flag every
    /// interval — disabling Connect and Save for the duration — and repaint the status line with
    /// "Synchronising…" while the user was reading something else. A background pass that makes the
    /// application feel intermittently broken is worse than a Sync button.
    /// </para>
    /// <para>
    /// So it is silent unless it has something to say: the status line changes only when the pass actually
    /// moved an item or produced something needing attention. It also yields to the user — a pass is
    /// skipped outright while a command is running, rather than queueing behind it.
    /// </para>
    /// </remarks>
    internal async Task AutoSyncAsync(CancellationToken cancellationToken)
    {
        if (IsBusy)
        {
            return;
        }

        await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// One background synchronisation pass, run whether or not a command is in flight.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The same quiet pass as <see cref="AutoSyncAsync" /> without the one thing that made it useless at
    /// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
    /// timed pass yields to is raised by the very command that opened the vault — and the pass on open
    /// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
    /// </para>
    /// <para>
    /// Yielding is right for every later pass, because by then a busy flag means a person is doing
    /// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
    /// </para>
    /// </remarks>
    internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
            {
                return;
            }

            var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);

            if (report is null)
            {
                return;
            }

            // A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
            // gets the treatment the catch below gives a total failure, the fact kept and the message
            // swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
            // reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
            // names the vault and the reason, because somebody who pressed it is waiting for an answer.
            if (IsWorthReporting(report))
            {
                Status = Describe(report);
            }

            await PruneLogsIfDueAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            // Locking, or closing.
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // The message is swallowed on purpose, and this is the one place in the view model where that
            // is right: a laptop that has been closed all afternoon would otherwise replace whatever the
            // user was reading with a socket error once a minute. Pressing Sync still reports the reason.
            //
            // The *fact* is not swallowed, and that is the half that used to be missing. Recording it is
            // what lets the titlebar stop claiming to be up to date with a server it cannot reach.
            LastSyncFailed = true;
        }
    }

    /// <remarks>
    /// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a
    /// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
    /// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
    /// </remarks>
    private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
        ISyncApi api,
        CancellationToken cancellationToken)
    {
        if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
        {
            return null;
        }

        try
        {
            // Every vault this session can read, not only the one new items are filed into. A team's
            // vault that never synced would show its hosts exactly once — at the unlock that first
            // pulled it — and then quietly stop, which reads as the feature not working.
            var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);

            // Not unconditionally false, which it was while a pass was one vault and a failure was an
            // exception. A failure is now a report — one unreachable team vault must not stop the others
            // syncing — so clearing the flag here regardless would light the titlebar green over a vault
            // that had just failed to sync, which is exactly the lie that flag exists to prevent.
            LastSyncFailed = report.Any(vault => !vault.Succeeded);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            // Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
            // snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here,
            // or a host a colleague approved stays a first-contact prompt until the next unlock.
            await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true);

            return report;
        }
        finally
        {
            syncGate.Release();
        }
    }

    /// <remarks>
    /// <c>ConfigureAwait(true)</c> throughout, and that is load-bearing rather than habit: the loop is
    /// started from the UI thread, so every continuation returns to it and the observable collections
    /// <see cref="LoadAsync" /> rebuilds are still only ever touched from one thread. A
    /// <c>ConfigureAwait(false)</c> here would mutate them from a timer thread, which Avalonia will
    /// eventually notice in a way that looks like a rendering bug.
    /// </remarks>
    private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
    {
        using var timer = new PeriodicTimer(AutoSyncInterval);

        try
        {
            // A pass on open, before the first tick. A vault edited on another machine should be current by
            // the time the user has finished reading the list, not a minute afterwards.
            //
            // Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
            // inside the unlock command, so the busy flag that pass yields to is raised by the very command
            // that opened the vault — and the pass on open therefore never ran at all. It was a silent
            // no-op that put the first synchronisation a full minute after unlock, on the launch where
            // being current matters most. The later passes keep the check: by then, a busy flag means a
            // user is doing something.
            await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);

            while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
            {
                await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
            }
        }
        catch (OperationCanceledException)
        {
            // Locking, or closing.
        }
    }

    /// <summary>Shows one kind of item, if nothing is being edited.</summary>
    /// <remarks>
    /// Takes the section rather than there being one command per kind, so a third kind is an enum member and
    /// a button and nothing else.
    /// </remarks>
    [RelayCommand]
    private void ShowSection(VaultSection target)
    {
        // Before the editor check, not after. Asking for the section already showing is not a request to leave
        // an editor, and refusing it would scold somebody for clicking where they already are.
        if (target == Section)
        {
            return;
        }

        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        Section = target;

        // Cleared rather than set to the section's name. The status line is shared with the account bar and
        // carries the result of the last thing that happened; "Hosts." would push a sync report or a save
        // confirmation off it to say something the selector is already showing.
        Status = string.Empty;
    }

    /// <summary>
    /// Raises the sheet that asks whether the thing being added is a host or a group.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two things behind one <c>+</c>, which is the design's arrangement and is also the honest one: a
    /// phone has room for one floating button, and "add" on this screen has genuinely been two operations
    /// since groups existed. The desktop asks the same question by having two buttons in two panels, which
    /// is what a 1280-pixel window can afford.
    /// </para>
    /// <para>
    /// Refuses while an editor is open rather than stacking on top of it. The button that raises this is
    /// hidden in that state — see <see cref="AnEditorIsOpen"/> — so this is the guard for the path the
    /// button does not control, which is a command invoked from anywhere else.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void OpenAddSheet()
    {
        if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
        {
            return;
        }

        IsAddSheetOpen = true;
    }

    /// <summary>Lowers the add sheet without choosing anything.</summary>
    [RelayCommand]
    private void CloseAddSheet() => IsAddSheetOpen = false;

    /// <summary>Starts a new host.</summary>
    [RelayCommand]
    private void NewHost()
    {
        IsAddSheetOpen = false;

        if (AHostEditorIsInTheWay())
        {
            return;
        }

        editingEntityId = null;
        editingHostVaultId = TargetVaultId;
        EditorLabel = string.Empty;
        EditorHostname = string.Empty;

        // Empty rather than 22, so a new host under a group that says 2222 is created wanting 2222 without
        // anybody typing it — and one under no group still dials 22, because that is where the chain ends.
        EditorPort = null;
        EditorUsername = string.Empty;
        EditorNotes = string.Empty;
        EditorRelayEnabled = false;
        editorTagIds = TagSet.Empty;
        EditorNewTag = string.Empty;
        BuildTagChoices();

        // A new host opens in whichever group is selected beside the list, if one is, because adding three
        // machines to the group somebody has just made is the ordinary case. Before the picker, because
        // whether there is a group to inherit from decides whether the picker offers to.
        BuildGroupChoices(SelectedGroup?.EntityId);

        BuildAuthenticationChoices(
            boundKeyId: null,
            boundCredentialId: null,
            asksForPassword: false,
            grouped: EditorSelectedGroup?.EntityId is not null);
        IsEditing = true;
        Status = "Adding a host.";
    }

    /// <summary>Opens the selected host for editing.</summary>
    [RelayCommand]
    private void EditSelectedHost()
    {
        if (SelectedHost is not { } row || AHostEditorIsInTheWay())
        {
            return;
        }

        if (row.IsReadOnly)
        {
            // Re-encoding would drop fields this build has no concept of, so the honest answer is to
            // refuse rather than to silently lose a colleague's data.
            Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        editingEntityId = row.EntityId;
        editingHostVaultId = row.VaultId;
        EditorLabel = row.Host.Label;
        EditorHostname = row.Host.Hostname;

        // The stored port, not the resolved one, and the difference is the whole feature: an inheriting host
        // opens with an empty box showing its group's value as a placeholder. Loading the resolved value
        // instead would fill the box, and saving would then pin what the host had deliberately left open.
        EditorPort = row.Host.Port;
        EditorUsername = row.Host.Username ?? string.Empty;
        EditorNotes = row.Host.Notes ?? string.Empty;
        EditorRelayEnabled = row.Host.RelayEnabled;
        editorTagIds = row.Host.TagIds;
        EditorNewTag = string.Empty;
        BuildTagChoices();

        BuildGroupChoices(row.Host.GroupId);

        BuildAuthenticationChoices(
            row.Host.SshKeyId,
            row.Host.CredentialId,
            row.Host.AsksForPassword is true,
            grouped: row.Host.GroupId is not null);

        IsEditing = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>
    /// Opens whichever editor the selected row belongs to.
    /// </summary>
    /// <remarks>
    /// One button over three kinds, because the table is one table. It delegates rather than duplicating:
    /// each kind's own command already knows how to refuse a read-only item and how to load an editor
    /// without a second decryption, and a merged copy of that would be a second place to get it wrong.
    /// </remarks>
    [RelayCommand]
    private void EditSelectedItem()
    {
        switch (SelectedVaultItem?.Kind)
        {
            case VaultItemKind.Key:
                EditSelectedKeyCommand.Execute(null);
                break;

            case VaultItemKind.Credential:
                EditSelectedCredentialCommand.Execute(null);
                break;

            case VaultItemKind.ObjectStore:
                EditObjectStoreCommand.Execute(null);
                break;

            case VaultItemKind.Tag:
                EditTagCommand.Execute(null);
                break;

            default:
                // A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
                break;
        }
    }

    /// <summary>Asks about deleting whatever the selected row is.</summary>
    /// <remarks>
    /// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
    /// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
    /// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
    /// </remarks>
    [RelayCommand]
    private void DeleteSelectedItem()
    {
        switch (SelectedVaultItem?.Kind)
        {
            case VaultItemKind.Key:
                DeleteKeyCommand.Execute(null);
                break;

            case VaultItemKind.Credential:
                DeleteCredentialCommand.Execute(null);
                break;

            case VaultItemKind.ObjectStore:
                DeleteObjectStoreCommand.Execute(null);
                break;

            case VaultItemKind.Tag:
                DeleteTagCommand.Execute(null);
                break;

            default:
                break;
        }
    }

    // AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
    // the sidebar's whole host list away under its one heading — an affordance that existed because that
    // list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
    // nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.

    /// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
    /// <remarks>
    /// <para>
    /// One box and one button for both, because a group is one field: a separate "rename" form would be the
    /// same text box with a different title. <see cref="EditingGroupId"/> is what decides which of the two
    /// this is, and it is set by <see cref="EditGroup"/> and cleared by everything else.
    /// </para>
    /// <para>
    /// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
    /// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
    /// second one would mean a name somebody chose on another machine could block one they choose here, at
    /// the next sync, with the rename already saved.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task SaveGroupAsync(CancellationToken cancellationToken)
    {
        var group = new HostGroupSecret
        {
            Label = GroupEditorLabel.Trim(),
            ParentId = GroupEditorSelectedParent?.EntityId,
            DefaultPort = GroupEditorDefaultPort,
            DefaultUsername = string.IsNullOrWhiteSpace(GroupEditorDefaultUsername)
                ? null
                : GroupEditorDefaultUsername.Trim(),
            DefaultSshKeyId = GroupBound(AuthenticationKind.SshKey),
            DefaultCredentialId = GroupBound(AuthenticationKind.Credential),
        };

        if (!group.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        var renaming = EditingGroupId;

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (renaming is { } entityId)
                {
                    await session.HostGroups
                        .UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    await session.HostGroups
                        .CreateAsync(session.ActiveVaultId, group, cancellationToken)
                        .ConfigureAwait(true);
                }

                ClearGroupEditor();

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{group.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Loads the selected group's name into the box, so saving renames it.</summary>
    [RelayCommand]
    private void EditGroup()
    {
        if (SelectedGroup is not { } row)
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This group was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        EditingGroupId = row.EntityId;
        GroupEditorLabel = row.Label;
        GroupEditorDefaultPort = row.Group.DefaultPort;
        GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;

        BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
        BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);

        IsEditingGroup = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>
    /// Opens a group's editor from its heading in the host list.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The phone's only route to <see cref="EditGroup"/>, and it exists because there is no other. The
    /// desktop reaches the group editor through the groups panel, which selects a
    /// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
    /// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
    /// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
    /// the header rather than the selection.
    /// </para>
    /// <para>
    /// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
    /// <c>+</c> that adds hosts with no way to correct one — and worse here, because a group's defaults are
    /// inherited: getting one wrong is wrong for every host beneath it at once.
    /// </para>
    /// <para>
    /// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
    /// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
    /// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
    /// opening an editor on nothing.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void EditGroupFromHeading(SidebarGroupHeader? header)
    {
        if (header?.GroupId is not { } groupId
            || Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
        {
            return;
        }

        SelectedGroup = row;
        EditGroupCommand.Execute(null);
    }

    /// <summary>Starts a new group.</summary>
    /// <remarks>
    /// The desktop never needed this command: its group editor is a bar that is always on screen, so
    /// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
    /// editor is a card that has to be raised — and raising it from a stale state would offer the last
    /// group's default key to the new one without anybody choosing it, which is what
    /// <see cref="ClearGroupEditor"/> prevents.
    /// </remarks>
    [RelayCommand]
    private void NewGroup()
    {
        IsAddSheetOpen = false;

        if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
        {
            return;
        }

        ClearGroupEditor();

        IsEditingGroup = true;
        Status = "Adding a group.";
    }

    /// <summary>
    /// Whether the group editor has to be dealt with before another editor opens.
    /// </summary>
    /// <remarks>
    /// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
    /// It answered only for the phone while the desktop's group editor was a bar that was always present.
    /// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on
    /// both, which is what it was always meant to do.
    /// </remarks>
    private bool AGroupEditorIsInTheWay()
    {
        if (IsEditingGroup)
        {
            Status = "Finish or cancel the group you are editing first.";
        }

        return IsEditingGroup;
    }

    /// <summary>The group picker's selection, if it names something of this kind.</summary>
    private Guid? GroupBound(AuthenticationKind kind) =>
        GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;

    /// <summary>
    /// Fills the parent picker, leaving out the group itself and everything beneath it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Refusing a descendant here is a convenience, not the guarantee.</b> It stops a cycle being made on
    /// this machine, which is worth doing because the alternative is a user watching their own sidebar go
    /// flat. What it cannot stop is a cycle assembled from two offline re-parents on two machines, neither
    /// of which was ever offered this list — so the walk itself carries a visited set. See
    /// <see cref="HostInheritance"/>.
    /// </para>
    /// <para>
    /// Descendants are found by walking each candidate <em>upwards</em> rather than this group downwards,
    /// because upwards is the direction the pointer goes and <see cref="HostInheritance.Chain"/> already
    /// terminates on a cycle. Walking down would need a child index this view model does not keep, and
    /// building one that has to survive a cycle is the same problem twice.
    /// </para>
    /// </remarks>
    private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
    {
        GroupEditorParentChoices.Clear();
        GroupEditorParentChoices.Add(GroupChoice.None);

        foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
        {
            var descends = HostInheritance
                .Chain(candidate.EntityId, groupsById)
                .Any(entry => entry.Id == groupId);

            if (!descends)
            {
                GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
            }
        }

        // A parent that is no longer selectable keeps a placeholder, so that editing a group's default port
        // cannot unparent it as a side effect — the same reason the host's group picker keeps one.
        if (parentId is { } bound && !GroupEditorParentChoices.Any(choice => choice.EntityId == bound))
        {
            GroupEditorParentChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
        }

        GroupEditorSelectedParent =
            GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == parentId)
            ?? GroupChoice.None;
    }

    /// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
    /// <remarks>
    /// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
    /// one. A group defaults to a key, to a credential, or to nothing — and "nothing" is the sentinel first
    /// entry, because a picker with no selection and a group that deliberately lends no binding look
    /// identical and are not the same thing.
    /// </remarks>
    private void BuildGroupAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId)
    {
        GroupEditorAuthenticationChoices.Clear();
        GroupEditorAuthenticationChoices.Add(AuthenticationChoice.NoDefault);

        foreach (var key in Keys)
        {
            GroupEditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
        }

        foreach (var credential in Credentials)
        {
            GroupEditorAuthenticationChoices.Add(
                AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
        }

        AddMissingGroupBinding(AuthenticationKind.SshKey, boundKeyId);
        AddMissingGroupBinding(AuthenticationKind.Credential, boundCredentialId);

        GroupEditorSelectedAuthentication = (boundKeyId, boundCredentialId) switch
        {
            ({ } key, _) => FindGroupBinding(AuthenticationKind.SshKey, key),
            (_, { } credential) => FindGroupBinding(AuthenticationKind.Credential, credential),
            _ => AuthenticationChoice.NoDefault,
        };
    }

    private void AddMissingGroupBinding(AuthenticationKind kind, Guid? boundId)
    {
        if (boundId is { } bound
            && !GroupEditorAuthenticationChoices.Any(
                choice => choice.Kind == kind && choice.EntityId == bound))
        {
            GroupEditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
        }
    }

    private AuthenticationChoice FindGroupBinding(AuthenticationKind kind, Guid entityId) =>
        GroupEditorAuthenticationChoices
            .FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
        ?? AuthenticationChoice.NoDefault;

    /// <summary>Empties every box in the group editor, so the next open starts from nothing.</summary>
    /// <remarks>
    /// One method rather than a line per field at each of the three places that clear it. A group editor
    /// left holding the last group's default key would lend it to the next group somebody created without
    /// anybody choosing it, which is the quiet kind of wrong.
    /// </remarks>
    private void ClearGroupEditor()
    {
        EditingGroupId = null;
        IsEditingGroup = false;
        GroupEditorLabel = string.Empty;
        GroupEditorDefaultPort = null;
        GroupEditorDefaultUsername = string.Empty;

        BuildGroupParentChoices(Guid.Empty, parentId: null);
        BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
    }

    /// <summary>Abandons a rename, leaving the box ready to create one instead.</summary>
    [RelayCommand]
    private void CancelGroupEdit()
    {
        ClearGroupEditor();
        Status = string.Empty;
    }

    /// <summary>Asks whether the selected group should go.</summary>
    /// <remarks>
    /// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
    /// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
    /// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
    /// to move, and that none of them are going anywhere else.
    /// </remarks>
    [RelayCommand]
    private void DeleteGroup()
    {
        if (SelectedGroup is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.Group,
            row.EntityId,
            $"Delete the group '{row.Label}'?",
            HowFarADeletionGoes("The group"),
            row.HostCount switch
            {
                0 => string.Empty,
                1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
                _ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
            });
    }

    /// <summary>Queues a tombstone for the group that was agreed to.</summary>
    private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That group is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.HostGroups
                    .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
                    .ConfigureAwait(true);

                if (EditingGroupId == entityId)
                {
                    EditingGroupId = null;
                    GroupEditorLabel = string.Empty;
                }

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Deleted the group '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Abandons the editor.</summary>
    [RelayCommand]
    private void CancelEdit()
    {
        IsEditing = false;
        editingEntityId = null;
        Status = string.Empty;
    }

    /// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
    [RelayCommand]
    private async Task SaveHostAsync(CancellationToken cancellationToken)
    {
        var host = BuildHost();

        if (!host.TryValidate(out var error))
        {
            Status = error;
            return;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (editingEntityId is { } entityId)
                {
                    await session.Hosts
                        .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    editingEntityId = await session.Hosts
                        .CreateAsync(editingHostVaultId, host, cancellationToken)
                        .ConfigureAwait(true);
                }

                IsEditing = false;
                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
                editingEntityId = null;

                Status = connection() is null
                    ? $"Saved '{host.Label}'. It will sync when you are online."
                    : $"Saved '{host.Label}'.";
            }).ConfigureAwait(true);

        // Pushed now rather than at the next tick. A change the user just made is the one they are most
        // likely to be about to look for on another machine.
        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Stores several hosts at once, the way one save does.
    /// </summary>
    /// <remarks>
    /// <para>
    /// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
    /// through the same repository, the same outbox and the same automatic push as saving one — the import
    /// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host to be
    /// written and no second place for the sync wiring to be forgotten.
    /// </para>
    /// <para>
    /// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
    /// thirty rebuilds of the host list and thirty sync passes, which on a slow link is minutes of the
    /// window doing nothing visible.
    /// </para>
    /// <para>
    /// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
    /// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
    /// </para>
    /// </remarks>
    internal async Task<int> ImportHostsAsync(
        IReadOnlyList<HostSecret> hosts,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(hosts);

        var imported = 0;
        var refused = 0;

        await RunAsync(
            hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
            async () =>
            {
                foreach (var host in hosts)
                {
                    if (!host.TryValidate(out _))
                    {
                        refused++;
                        continue;
                    }

                    await session.Hosts
                        .CreateAsync(session.ActiveVaultId, host, cancellationToken)
                        .ConfigureAwait(true);

                    imported++;
                }

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";

                Status = connection() is null
                    ? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
                    : $"Imported {imported} host(s).{refusals}";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);

        return imported;
    }

    /// <summary>Asks whether the selected host should go.</summary>
    /// <remarks>
    /// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
    /// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
    /// deleting a machine they are still working on should know that is what they have done.
    /// </remarks>
    [RelayCommand]
    private void DeleteHost()
    {
        if (SelectedHost is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.Host,
            row.EntityId,
            $"Delete the host '{row.Label}'?",
            HowFarADeletionGoes("The host and everything saved about it"),
            row.IsConnected
                ? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
                    + "nothing will reopen it afterwards."
                : string.Empty);
    }

    /// <summary>Queues a tombstone for the host that was agreed to.</summary>
    private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            // Gone between the question and the answer — a sync that pulled somebody else's deletion is the
            // realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
            Status = "That host is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.Hosts
                    .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
                    .ConfigureAwait(true);

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Deleted '{row.Label}'.";
            }).ConfigureAwait(true);

        // As with saving: a tombstone is worth pushing straight away, so the item does not reappear on
        // another machine that syncs before the next tick.
        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Starts a new SSH key.</summary>
    [RelayCommand]
    private void NewKey()
    {
        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        Section = VaultSection.Keys;
        editingKeyId = null;
        editingKeyVaultId = TargetVaultId;
        ClearKeyEditor();
        IsEditingKey = true;
        Status = "Adding an SSH key.";
    }

    /// <summary>Opens the selected key for editing.</summary>
    /// <remarks>
    /// The private key is loaded into the editor, which is the only way an edit can preserve it: the
    /// codec has no notion of a partial update, so saving re-encodes every field.
    /// </remarks>
    [RelayCommand]
    private void EditSelectedKey()
    {
        if (SelectedKey is not { } row || AVaultEditorIsInTheWay())
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This key was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        Section = VaultSection.Keys;
        editingKeyId = row.EntityId;
        editingKeyVaultId = row.VaultId;
        KeyEditorLabel = row.Key.Label;
        KeyEditorPrivateKey = row.Key.PrivateKeyPem;
        KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
        KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty;
        KeyEditorNotes = row.Key.Notes ?? string.Empty;
        IsEditingKey = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>Opens the form in front of generating a key.</summary>
    [RelayCommand]
    private void NewGeneratedKey()
    {
        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        Section = VaultSection.Keys;
        GenerateComment = $"{Environment.UserName}@{Environment.MachineName}";
        GenerateAlgorithm = SshKeyAlgorithm.Ed25519;
        IsGeneratingKey = true;
        Status = "Generating a new SSH key.";
    }

    /// <summary>Chooses which kind of key to make.</summary>
    /// <remarks>
    /// A command and two buttons rather than a selector bound to <see cref="GenerateAlgorithm"/>, which is
    /// the idiom the category rail already uses and for the same reason: a selector moves its own highlight
    /// before anything here can decide, so it can end up showing a choice that was not made.
    /// </remarks>
    [RelayCommand]
    private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm;

    /// <summary>Abandons the generate form without making anything.</summary>
    [RelayCommand]
    private void CancelGenerateKey()
    {
        IsGeneratingKey = false;
        GenerateComment = string.Empty;
        Status = string.Empty;
    }

    /// <summary>
    /// Makes a new key pair and drops it into the key editor, unsaved.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>It does not save.</b> What comes back lands in the editor and waits for SAVE, so the whole
    /// storage path — validation, encoding, the outbox, the push — is the one that already exists and this
    /// command has no second version of it. It also means a generated key can be renamed or annotated
    /// before it is written, and abandoned by pressing CANCEL.
    /// </para>
    /// <para>
    /// <b>Off the UI thread.</b> RSA at 4096 bits is seconds of solid CPU, which on this thread is a frozen
    /// window at the moment somebody is watching it — the same reason key derivation runs on a worker. The
    /// generator does not know which algorithm is cheap, and neither should this.
    /// </para>
    /// <para>
    /// The private key exists in memory from here until the editor is cleared, as a pasted one does. See
    /// <c>SshKeySecret</c> for why a .NET string is the honest choice for that and what it does not buy.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task GenerateKeyAsync(CancellationToken cancellationToken)
    {
        var algorithm = GenerateAlgorithm;
        var comment = string.IsNullOrWhiteSpace(GenerateComment)
            ? $"{Environment.UserName}@{Environment.MachineName}"
            : GenerateComment.Trim();

        var kind = algorithm is SshKeyAlgorithm.Rsa4096 ? "RSA 4096-bit" : "Ed25519";

        await RunAsync(
            $"Generating a {kind} key…",
            async () =>
            {
                var generated = await Task
                    .Run(() => SshKeyGenerator.Generate(algorithm, comment), cancellationToken)
                    .ConfigureAwait(true);

                IsGeneratingKey = false;
                editingKeyId = null;

                // Filed where a pasted key would be, and set here rather than left over from whatever was
                // edited last: this path opens the same editor without going through NewKey, so without
                // this a key generated after editing a team's key would be saved into that team's vault.
                editingKeyVaultId = TargetVaultId;

                ClearKeyEditor();

                KeyEditorLabel = comment;
                KeyEditorPrivateKey = generated.PrivateKeyArmour;
                KeyEditorPublicKey = generated.PublicKeyLine;
                KeyEditorNotes = $"Generated by DodoSSH. {generated.Fingerprint}";

                IsEditingKey = true;

                Status = $"Generated {generated.Fingerprint}. Nothing is stored until you press SAVE.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Puts the selected key's public half on the clipboard.
    /// </summary>
    /// <remarks>
    /// The public half only, and there is deliberately no command for the other one. Installing a key means
    /// pasting this line into a host's <c>authorized_keys</c>; a private key on a clipboard is a private key
    /// in every application on the machine and in whatever syncs it between them.
    /// </remarks>
    [RelayCommand]
    private async Task CopyPublicKeyAsync()
    {
        if (SelectedKey is not { } row)
        {
            Status = "Choose a key first.";
            return;
        }

        if (row.Key.PublicKey is not { Length: > 0 } line)
        {
            // Not derivable here: SshKeySecret stores whatever armour it was given and declines to parse
            // it, so a key imported without its .pub has no public half to offer. Saying so beats copying
            // an empty string.
            Status = $"'{row.Label}' has no public half stored. Paste it into the key's editor to keep it.";
            return;
        }

        if (copyToClipboard is null)
        {
            Status = "This machine has no clipboard.";
            return;
        }

        await copyToClipboard(line).ConfigureAwait(true);

        Status = $"Copied the public key for '{row.Label}'. Add it to the host's ~/.ssh/authorized_keys.";
    }

    /// <summary>Abandons the key editor, clearing the material out of it.</summary>
    [RelayCommand]
    private void CancelKeyEdit()
    {
        IsEditingKey = false;
        editingKeyId = null;
        ClearKeyEditor();
        Status = string.Empty;
    }

    /// <summary>Stores the key editor's contents, encrypted, and queues it for the server.</summary>
    [RelayCommand]
    private async Task SaveKeyAsync(CancellationToken cancellationToken)
    {
        var key = BuildKey();

        if (!key.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (editingKeyId is { } entityId)
                {
                    await session.SshKeys
                        .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    editingKeyId = await session.SshKeys
                        .CreateAsync(editingKeyVaultId, key, cancellationToken)
                        .ConfigureAwait(true);
                }

                IsEditingKey = false;
                ClearKeyEditor();

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId);
                editingKeyId = null;

                Status = connection() is null
                    ? $"Saved '{key.Label}'. It will sync when you are online."
                    : $"Saved '{key.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Asks whether the selected key should go.</summary>
    /// <remarks>
    /// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
    /// why the question says so. What it does not say is that the key is gone from the machines it was
    /// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
    /// server is not something this application has ever written to.
    /// </remarks>
    [RelayCommand]
    private void DeleteKey()
    {
        if (SelectedKey is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.Key,
            row.EntityId,
            $"Delete the SSH key '{row.Label}'?",
            HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
                + " If this key is not on disk anywhere else, this is the only copy.",
            HostsBoundTo(ResolvedBindingKind.SshKey, row.EntityId));
    }

    /// <summary>Queues a tombstone for the key that was agreed to.</summary>
    private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That key is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.SshKeys
                    .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
                    .ConfigureAwait(true);

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Deleted '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Starts a new credential.</summary>
    [RelayCommand]
    private void NewCredential()
    {
        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        Section = VaultSection.Credentials;
        editingCredentialId = null;
        editingCredentialVaultId = TargetVaultId;
        ClearCredentialEditor();
        IsEditingCredential = true;
        Status = "Adding a credential.";
    }

    /// <summary>Opens the selected credential for editing.</summary>
    /// <remarks>
    /// The password is loaded into the editor, as the private key is and for the same reason: the codec has no
    /// notion of a partial update, so saving re-encodes every field.
    /// </remarks>
    [RelayCommand]
    private void EditSelectedCredential()
    {
        if (SelectedCredential is not { } row || AVaultEditorIsInTheWay())
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This credential was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        Section = VaultSection.Credentials;
        editingCredentialId = row.EntityId;
        editingCredentialVaultId = row.VaultId;
        CredentialEditorLabel = row.Credential.Label;
        CredentialEditorUsername = row.Credential.Username ?? string.Empty;
        CredentialEditorPassword = row.Credential.Password;
        CredentialEditorNotes = row.Credential.Notes ?? string.Empty;
        IsEditingCredential = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>Starts a new bucket.</summary>
    /// <remarks>
    /// Path-style addressing starts off, which is the AWS default — and <see cref="EditObjectStore"/> loads
    /// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why.
    /// </remarks>
    [RelayCommand]
    private void NewObjectStore()
    {
        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        Section = VaultSection.Buckets;
        editingObjectStoreId = null;
        ClearObjectStoreEditor();
        IsEditingObjectStore = true;
        Status = "Adding a bucket.";
    }

    /// <summary>Opens the selected bucket for editing.</summary>
    [RelayCommand]
    private void EditObjectStore()
    {
        if (SelectedObjectStore is not { } row || AVaultEditorIsInTheWay())
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This bucket was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        Section = VaultSection.Buckets;
        editingObjectStoreId = row.EntityId;
        BucketEditorLabel = row.Store.Label;
        BucketEditorBucket = row.Store.Bucket;
        BucketEditorAccessKeyId = row.Store.AccessKeyId;
        BucketEditorSecretAccessKey = row.Store.SecretAccessKey;
        BucketEditorRegion = row.Store.Region ?? string.Empty;
        BucketEditorEndpoint = row.Store.Endpoint ?? string.Empty;
        BucketEditorUsePathStyle = row.Store.UsePathStyle;
        BucketEditorNotes = row.Store.Notes ?? string.Empty;
        IsEditingObjectStore = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>Abandons the bucket editor, clearing the secret access key out of it.</summary>
    [RelayCommand]
    private void CancelObjectStoreEdit()
    {
        IsEditingObjectStore = false;
        editingObjectStoreId = null;
        ClearObjectStoreEditor();
        Status = string.Empty;
    }

    /// <summary>Stores the bucket editor's contents, encrypted, and queues it for the server.</summary>
    [RelayCommand]
    private async Task SaveObjectStoreAsync(CancellationToken cancellationToken)
    {
        var store = BuildObjectStore();

        if (!store.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (editingObjectStoreId is { } entityId)
                {
                    await session.ObjectStores
                        .UpdateAsync(session.ActiveVaultId, entityId, store, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    editingObjectStoreId = await session.ObjectStores
                        .CreateAsync(session.ActiveVaultId, store, cancellationToken)
                        .ConfigureAwait(true);
                }

                IsEditingObjectStore = false;
                ClearObjectStoreEditor();

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                SelectedObjectStore = ObjectStores
                    .FirstOrDefault(row => row.EntityId == editingObjectStoreId);
                editingObjectStoreId = null;

                Status = connection() is null
                    ? $"Saved '{store.Label}'. It will sync when you are online."
                    : $"Saved '{store.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Starts a new tag from the keychain screen.</summary>
    /// <remarks>
    /// The second way to make one. The first — the host editor's box — is where it usually happens, because
    /// wanting a tag and tagging a host are the same moment. This is for the times they are not: setting up
    /// a scheme before there is anything to put it on.
    /// </remarks>
    [RelayCommand]
    private void NewTag()
    {
        if (AVaultEditorIsInTheWay())
        {
            return;
        }

        editingTagId = null;

        // The active vault, not the "new items go to" picker, and this is the one place the difference
        // bites. Keys and credentials are read across every readable vault, so filing one into a team's is
        // safe — it comes back in the list. Tags are read like groups and buckets are: the editable list is
        // the active vault's alone. A tag filed anywhere else would be created, queued for push, reported
        // as added, and then invisible — no row, no count, no entry in any host editor's picker, and
        // nothing on this screen able to rename or delete it, because there is no active-vault switcher to
        // go and find it with. NewObjectStore ignores the picker for exactly this reason.
        editingTagVaultId = session.ActiveVaultId;
        TagEditorLabel = string.Empty;
        IsEditingTag = true;
        Status = "Adding a tag.";
    }

    /// <summary>Loads the selected tag's name into the box, so saving renames it.</summary>
    [RelayCommand]
    private void EditTag()
    {
        if (SelectedTag is not { } row || AVaultEditorIsInTheWay())
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This tag was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        editingTagId = row.EntityId;
        editingTagVaultId = session.ActiveVaultId;
        TagEditorLabel = row.Label;
        IsEditingTag = true;
        Status = $"Renaming {row.Label}.";
    }

    /// <summary>Abandons the tag editor.</summary>
    [RelayCommand]
    private void CancelTagEdit()
    {
        IsEditingTag = false;
        editingTagId = null;
        TagEditorLabel = string.Empty;
    }

    /// <summary>
    /// Stores the tag in the box, encrypted, and queues it for the server.
    /// </summary>
    /// <remarks>
    /// <b>A rename is one write, and that is the whole reason this type exists.</b> Every host wearing the
    /// tag names its id, so none of them is touched — which is what makes renaming safe to do casually and
    /// what a string repeated inside twenty payloads could never have offered. See <see cref="TagSecret"/>.
    /// </remarks>
    [RelayCommand]
    private async Task SaveTagAsync(CancellationToken cancellationToken)
    {
        var tag = new TagSecret { Label = TagEditorLabel.Trim() };

        if (!tag.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        var renaming = editingTagId;

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (renaming is { } entityId)
                {
                    await session.Tags
                        .UpdateAsync(editingTagVaultId, entityId, tag, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    await session.Tags
                        .CreateAsync(editingTagVaultId, tag, cancellationToken)
                        .ConfigureAwait(true);
                }

                CancelTagEdit();

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = renaming is null
                    ? $"Added the tag '{tag.Label}'."
                    : $"Renamed to '{tag.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Asks whether the selected tag should go.</summary>
    /// <remarks>
    /// The consequence is worth counting rather than describing. Deleting a tag does not touch the hosts
    /// wearing it — rewriting N payloads inside one delete is what keeping membership on the host exists to
    /// avoid — so what actually happens is that N rows lose a chip, and the number is the difference
    /// between a tidy-up and losing a filter somebody relies on.
    /// </remarks>
    [RelayCommand]
    private void DeleteTag()
    {
        if (SelectedTag is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.Tag,
            row.EntityId,
            $"Delete the tag '{row.Label}'?",
            HowFarADeletionGoes("The tag"),
            row.HostCount == 0
                ? string.Empty
                : row.HostCount == 1
                    ? "1 host wears it and will simply stop showing the chip. Nothing else about it changes."
                    : $"{row.HostCount} hosts wear it and will simply stop showing the chip. Nothing else "
                        + "about them changes.");
    }

    /// <summary>Queues a tombstone for the tag that was agreed to.</summary>
    private async Task DeleteTagNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Tags.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That tag is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.Tags
                    .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
                    .ConfigureAwait(true);

                if (editingTagId == entityId)
                {
                    CancelTagEdit();
                }

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = $"Deleted the tag '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Asks whether the selected bucket should go.</summary>
    /// <remarks>
    /// The bucket itself is untouched, and the question says so. Removing the entry here removes this
    /// keychain's way of reaching it — the objects in it are somebody else's to delete, and a confirmation
    /// that did not distinguish the two would be genuinely frightening.
    /// </remarks>
    [RelayCommand]
    private void DeleteObjectStore()
    {
        if (SelectedObjectStore is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.ObjectStore,
            row.EntityId,
            $"Remove the bucket '{row.Label}'?",
            HowFarADeletionGoes("The bucket's address and its keys"),
            "Nothing in the bucket is touched. This removes the way this keychain reaches it, not the "
                + "objects in it.");
    }

    /// <summary>Queues a tombstone for the bucket that was agreed to.</summary>
    private async Task DeleteObjectStoreNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (ObjectStores.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That bucket is no longer here, so nothing was removed.";
            return;
        }

        await RunAsync(
            "Removing…",
            async () =>
            {
                await session.ObjectStores
                    .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
                    .ConfigureAwait(true);

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Removed '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Empties the bucket editor, including the secret access key.</summary>
    private void ClearObjectStoreEditor()
    {
        BucketEditorLabel = string.Empty;
        BucketEditorBucket = string.Empty;
        BucketEditorAccessKeyId = string.Empty;
        BucketEditorSecretAccessKey = string.Empty;
        BucketEditorRegion = string.Empty;
        BucketEditorEndpoint = string.Empty;
        BucketEditorUsePathStyle = false;
        BucketEditorNotes = string.Empty;
    }

    private ObjectStoreSecret BuildObjectStore() =>
        new()
        {
            Label = BucketEditorLabel.Trim(),
            Bucket = BucketEditorBucket.Trim(),

            // Trimmed, both of them. A pasted access key with a trailing newline signs every request wrongly
            // and the service answers "SignatureDoesNotMatch", which names neither the field nor the paste.
            AccessKeyId = BucketEditorAccessKeyId.Trim(),
            SecretAccessKey = BucketEditorSecretAccessKey.Trim(),

            // Blank is a real answer for both — no region, or no custom endpoint — and is stored as null so
            // that ObjectStoreSecret can tell "not set" from "set to nothing".
            Region = string.IsNullOrWhiteSpace(BucketEditorRegion) ? null : BucketEditorRegion.Trim(),
            Endpoint = string.IsNullOrWhiteSpace(BucketEditorEndpoint)
                ? null
                : BucketEditorEndpoint.Trim().TrimEnd('/'),
            UsePathStyle = BucketEditorUsePathStyle,
            Notes = string.IsNullOrWhiteSpace(BucketEditorNotes) ? null : BucketEditorNotes,
        };

    /// <summary>Abandons the credential editor, clearing the password out of it.</summary>
    [RelayCommand]
    private void CancelCredentialEdit()
    {
        IsEditingCredential = false;
        editingCredentialId = null;
        ClearCredentialEditor();
        Status = string.Empty;
    }

    /// <summary>Stores the credential editor's contents, encrypted, and queues it for the server.</summary>
    [RelayCommand]
    private async Task SaveCredentialAsync(CancellationToken cancellationToken)
    {
        var credential = BuildCredential();

        if (!credential.TryValidate(out var reason))
        {
            Status = reason;
            return;
        }

        await RunAsync(
            "Saving…",
            async () =>
            {
                if (editingCredentialId is { } entityId)
                {
                    await session.Credentials
                        .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
                        .ConfigureAwait(true);
                }
                else
                {
                    editingCredentialId = await session.Credentials
                        .CreateAsync(editingCredentialVaultId, credential, cancellationToken)
                        .ConfigureAwait(true);
                }

                IsEditingCredential = false;
                ClearCredentialEditor();

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                SelectedCredential = Credentials
                    .FirstOrDefault(row => row.EntityId == editingCredentialId);
                editingCredentialId = null;

                Status = connection() is null
                    ? $"Saved '{credential.Label}'. It will sync when you are online."
                    : $"Saved '{credential.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Asks whether the selected credential should go.</summary>
    [RelayCommand]
    private void DeleteCredential()
    {
        if (SelectedCredential is not { } row)
        {
            return;
        }

        PendingDeletion = new DeletionRequest(
            DeletionTarget.Credential,
            row.EntityId,
            $"Delete the password '{row.Label}'?",
            HowFarADeletionGoes("The password and the account saved with it"),
            HostsBoundTo(ResolvedBindingKind.Credential, row.EntityId));
    }

    /// <summary>Queues a tombstone for the credential that was agreed to.</summary>
    private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
    {
        if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            Status = "That password is no longer here, so nothing was deleted.";
            return;
        }

        await RunAsync(
            "Deleting…",
            async () =>
            {
                await session.Credentials
                    .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
                    .ConfigureAwait(true);

                await ReloadAsync(cancellationToken).ConfigureAwait(true);
                Status = $"Deleted '{row.Label}'.";
            }).ConfigureAwait(true);

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Carries out the deletion that was asked about.</summary>
    /// <remarks>
    /// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a
    /// second press during a slow round trip has nothing left to agree to.
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
    {
        if (PendingDeletion is not { } request)
        {
            return;
        }

        PendingDeletion = null;

        switch (request.Target)
        {
            case DeletionTarget.Host:
                await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            case DeletionTarget.Key:
                await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            case DeletionTarget.Credential:
                await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            case DeletionTarget.Group:
                await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            case DeletionTarget.ObjectStore:
                await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            case DeletionTarget.Tag:
                await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
                break;

            default:
                break;
        }
    }

    /// <summary>Thinks better of it.</summary>
    [RelayCommand]
    private void CancelDelete() => PendingDeletion = null;

    /// <summary>
    /// Where a deleted item goes, and how far.
    /// </summary>
    /// <remarks>
    /// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
    /// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
    /// credential because it leaked should be told which of those two they have just done.
    /// </remarks>
    private string HowFarADeletionGoes(string what) => connection() is null
        ? $"{what} goes from this machine now, and from your other machines once this one is online again. "
            + "There is no undo."
        : $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
            + "There is no undo.";

    /// <summary>
    /// What the hosts that authenticate with an item would be left with, or nothing when none do.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Counted rather than warned about in general terms. The number is the difference between a sentence
    /// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
    /// to something the vault no longer has is refused at connect time rather than quietly falling back to a
    /// typed password — see <see cref="TryBuildAuthentication" />.
    /// </para>
    /// <para>
    /// <b>Counted over the resolved binding, so a group's default is counted too.</b> Reading each host's own
    /// ids would miss a key that only a group names — which is the worst case rather than an edge one: a key
    /// bound once on a group and inherited by twenty hosts would warn about nobody, be deleted, and then
    /// refuse all twenty at connect time.
    /// </para>
    /// </remarks>
    private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
    {
        var bound = Hosts
            .Where(row => row.Resolved.Binding is { } binding
                && binding.Kind == kind
                && binding.EntityId == entityId)
            .Select(row => row.Label)
            .ToArray();

        if (bound.Length == 0)
        {
            return string.Empty;
        }

        // Three names and a count past that, because this is read in a 244-pixel column and a vault with
        // twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
        var named = bound.Length <= 3
            ? string.Join(", ", bound)
            : $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";

        return bound.Length == 1
            ? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
                + "password."
            : $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
                + "fall back to a typed password.";
    }

    /// <summary>
    /// Withdraws trust from the selected pin's endpoint.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Goes through the same <c>ForgetAsync</c> as the host editor's button, which withdraws every pin for
    /// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust
    /// is about an address, a second pin for the same address under another algorithm would go on being
    /// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided
    /// to stop trusting one of its keys. The status line says how many went.
    /// </para>
    /// <para>
    /// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the
    /// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the
    /// prompt at connect time.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ForgetPinAsync(CancellationToken cancellationToken)
    {
        if (SelectedKnownHost is not { } row)
        {
            return;
        }

        await RunAsync(
            $"Forgetting the pinned host key for {row.Host}…",
            async () =>
            {
                var forgotten = await knownHosts
                    .ForgetAsync(row.Host, row.Port, cancellationToken)
                    .ConfigureAwait(true);

                // A mismatch the user was staring at is about a pin that may have just gone.
                HostKeyMismatch = null;

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = forgotten == 1
                    ? $"Forgot the pinned key for {row.Host}:{row.Port}."
                    : $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}.";
            }).ConfigureAwait(true);

        // Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to
        // a server that has been rebuilt.
        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Opens a terminal on the selected host.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> That gate is
    /// what makes the vault do one thing at a time, and connecting is the one operation that must not hold
    /// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate
    /// for it means a window in which nothing else can be saved, edited or even connected to. The strip
    /// carries the feedback instead — <see cref="ConnectionStarting"/> puts a tab there before anything is
    /// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
    /// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
    /// </para>
    /// <para>
    /// Several connections can therefore be in flight at once, which is why an attempt has an id and why
    /// this command allows concurrent executions. That is a feature rather than a tolerated race: opening
    /// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting
    /// for each in turn. Without the flag the generated command refuses a second call outright while the
    /// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of
    /// the explanation.
    /// </para>
    /// <para>
    /// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
    /// <c>[RelayCommand]</c> over a method that takes one generates a command which cancels the previous
    /// execution's token every time it is invoked — so a second connection would quietly abandon the first,
    /// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the
    /// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not
    /// having one is a way to abort a handshake from here; closing the tab is that, and the session it
    /// abandons is adopted rather than lost. See <c>MainWindowViewModel.CloseTabAsync</c>.
    /// </para>
    /// </remarks>
    [RelayCommand(AllowConcurrentExecutions = true)]
    private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);

    /// <inheritdoc cref="ConnectAsync" />
    /// <param name="cancellationToken">
    /// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
    /// which is a different command's and so is not cancelled by anyone else connecting.
    /// </param>
    private async Task ConnectToSelectedHostAsync(CancellationToken cancellationToken)
    {
        if (SelectedHost is not { } row)
        {
            Status = "Choose a host first.";
            return;
        }

        // Refused rather than quietly falling back to the password box. A host set up for key-only access
        // that silently starts offering a password is the failure worth ruling out — the user asked for one
        // thing and got another, and the host is the last place that would say so.
        if (!TryBuildAuthentication(
            row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
        {
            Status = refusal;
            return;
        }

        PendingHostKey = null;
        HostKeyMismatch = null;

        // Before the first await, so the tab is in the strip in the same turn the user asked for it. The
        // address is the one that will actually be dialled — a bound credential can supply the username —
        // rather than the host's own fields, so the tab does not rename itself on connecting.
        var attempt = new ConnectionAttemptEventArgs(
            Guid.CreateVersion7(),
            row.Label,
            Dialled(row, authentication));

        ConnectionStarting?.Invoke(this, attempt);
        Status = $"Connecting to {row.Label}…";

        await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Pins the offered host key and retries.
    /// </summary>
    /// <remarks>
    /// The pin goes into the vault, so this writes to the local cache and queues a change for every other
    /// machine — which is why the write is guarded and the connection is only retried once it has landed.
    /// It used to be a dictionary insert that could not fail; reporting a failed write as a failed
    /// connection would send the user looking at the host.
    /// </remarks>
    [RelayCommand]
    private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
    {
        if (PendingHostKey is not { } presentation)
        {
            return;
        }

        try
        {
            await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            Status = "Cancelled.";
            return;
        }
        catch (Exception exception)
        {
            Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
            return;
        }

        PendingHostKey = null;

        await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true);

        // After connecting, not before. A pin is worth pushing straight away — the same host on another
        // machine should not ask again — but not at the cost of delaying the connection the user asked for.
        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Dismisses the trust prompt without pinning anything.</summary>
    [RelayCommand]
    private void RejectHostKey()
    {
        PendingHostKey = null;
        Status = "The host key was not trusted, so nothing was connected.";
    }

    /// <summary>
    /// Withdraws trust from every key pinned for the host being edited.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is
    /// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for
    /// ever without this. It is deliberately <em>here</em> — in the host's editor, reached by choosing to edit
    /// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same
    /// button as "continue anyway" with two clicks instead of one.
    /// </para>
    /// <para>
    /// Applies to the host's <em>saved</em> address rather than whatever the editor's boxes currently hold.
    /// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a
    /// hostname has not moved it yet.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ForgetHostKeyAsync(CancellationToken cancellationToken)
    {
        if (editingEntityId is not { } entityId
            || Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
        {
            return;
        }

        var address = row.Host.Hostname;

        // The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its
        // group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch
        // exactly where it was.
        var port = Resolve(row.Host).Port.Value;

        await RunAsync(
            $"Forgetting the pinned host key for {address}…",
            async () =>
            {
                var forgotten = await knownHosts
                    .ForgetAsync(address, port, cancellationToken)
                    .ConfigureAwait(true);

                // The refusal that sent the user here is about a pin that no longer exists.
                HostKeyMismatch = null;

                PendingChanges = await session
                    .PendingChangeCountAsync(cancellationToken)
                    .ConfigureAwait(true);

                if (forgotten == 0)
                {
                    Status = $"Nothing was pinned for {address}:{port}.";
                    return;
                }

                Status = $"Forgot the pinned host key for {address}:{port}. The next connection will "
                    + "ask you to check its fingerprint again.";
            }).ConfigureAwait(true);

        // Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would
        // leave the other ones refusing to connect to a server that has been rebuilt.
        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Marks every shown conflict as seen.
    /// </summary>
    /// <remarks>
    /// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
    /// dismisses this and realises a minute later that they wanted the other value should still be able to
    /// get it.
    /// </remarks>
    [RelayCommand]
    private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
    {
        foreach (var conflict in Conflicts.ToArray())
        {
            await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
        }

        await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;

        // The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight
        // holds the vault keys and the cache; letting it run on into a disposed session is how locking
        // turns into an ObjectDisposedException on a background thread that nobody sees.
        if (autoSync is not null)
        {
            await autoSync.CancelAsync().ConfigureAwait(false);
        }

        if (autoSyncLoop is not null)
        {
            await autoSyncLoop.ConfigureAwait(false);
        }

        autoSync?.Dispose();
        syncGate.Dispose();

        await session.DisposeAsync().ConfigureAwait(false);
    }

    /// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
    /// <remarks>
    /// <para>
    /// The renderer has to be attached before a session opens: the transport drops frames when nothing is
    /// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
    /// output at a terminal that was never created. That wait is bounded and takes this command's token, so
    /// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
    /// </para>
    /// <para>
    /// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
    /// operation has timed out", and the one thing worth saying is where to look: a runtime this application
    /// does not install.
    /// </para>
    /// <para>
    /// <b>An unknown host key is not a failure and is deliberately not logged.</b> Nothing was refused and
    /// nothing broke — the connection is paused on a question, and it becomes a session the moment the user
    /// answers it. An entry here would record a failure that did not happen, once per new host. A changed
    /// key <em>is</em> logged, and it is the entry the connection log most exists for: it is refused outright
    /// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses.
    /// </para>
    /// <para>
    /// <b>Everything else is caught by shape rather than by type.</b> This project's SSH layer defines only
    /// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not
    /// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded
    /// before it is reported — the log is an observer here and must never become the thing that swallows an
    /// error. Cancellation is excluded from that, because a user who gave up did not fail to connect.
    /// </para>
    /// </remarks>
    private async Task OpenSessionAsync(
        ConnectionAttemptEventArgs attempt,
        HostRowViewModel row,
        HostAuthentication authentication,
        CancellationToken cancellationToken)
    {
        try
        {
            await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
                .ConfigureAwait(true);
        }
        catch (TimeoutException)
        {
            Abandon(
                attempt,
                "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
                + "runtime is probably missing or blocked; install it and try again.");
        }
        catch (SshHostKeyUnknownException exception)
        {
            PendingHostKey = exception.Presentation;
            Answer(attempt, "This host has not been seen before.");
        }
        catch (SshHostKeyMismatchException exception)
        {
            RecordFailure(row, authentication, ConnectionOutcome.Refused);

            HostKeyMismatch = exception.Message;
            Answer(attempt, "The host key has changed. The connection was refused.");
        }
        catch (OperationCanceledException)
        {
            Answer(attempt, "Cancelled.");
        }
        catch (Exception exception)
        {
            RecordFailure(row, authentication, ConnectionOutcome.Failed);
            Abandon(attempt, exception.Message);
        }
    }

    /// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
    /// <remarks>
    /// The reason goes to two places on purpose. The status line is where somebody watching this screen is
    /// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary
    /// case, because connecting does not hold the window still any more.
    /// </remarks>
    private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
    {
        Status = reason;

        ConnectionFailed?.Invoke(
            this,
            new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
    }

    /// <summary>
    /// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
    /// </summary>
    /// <remarks>
    /// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away
    /// so the window can show the question instead. See <see cref="ConnectionFailedEventArgs"/>. Cancelling
    /// counts as a question in the sense that matters here — the tab is going either way, and nothing about
    /// it is worth keeping on screen.
    /// </remarks>
    private void Answer(ConnectionAttemptEventArgs attempt, string status)
    {
        Status = status;

        ConnectionFailed?.Invoke(
            this,
            new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
    }

    /// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
    /// <remarks>
    /// Split from the handlers around it for length, and the split falls where it should: this is the whole
    /// happy path, and everything above it is one <c>catch</c> per way of not having one.
    /// </remarks>
    private async Task ConnectAndAnnounceAsync(
        ConnectionAttemptEventArgs attempt,
        HostRowViewModel row,
        HostAuthentication authentication,
        CancellationToken cancellationToken)
    {
        await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);

        var request = new SshConnectionRequest(
            row.Host.Hostname,
            Resolve(row.Host).Port.Value,
            authentication.Username,
            authentication.Credential);

        var sessionId = await workspace
            .OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
            .ConfigureAwait(true);

        // The workspace has already opened a ticket for this session, with the address and the moment it
        // connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
        // notion of one — so the name is added here rather than the ticket being replaced, which would move
        // the start time to now.
        connectionLog?.Identify(sessionId, row.Label, row.EntityId);

        Status = $"Connected to {row.Label}.";

        // Only now, and only on success. The page's own term.focus() focuses the textarea inside the
        // document, which does nothing while the window's keyboard focus is still on the Connect button — so
        // without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
        SessionOpened?.Invoke(
            this,
            new TerminalSessionEventArgs(
                attempt.AttemptId,
                sessionId,
                row.Label,
                Dialled(row, authentication)));

        // Last, and after the tab exists: keeping the password is a favour, and the session the user asked
        // for must not wait on a vault write to appear.
        await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Turns the password that just worked into a keychain credential bound to this host.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
    /// bind whatever was in the box — including the typo that is about to be refused — and the host would
    /// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
    /// keychain is where the wrong password now lives.
    /// </para>
    /// <para>
    /// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
    /// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
    /// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
    /// on why the binding is an id and not a copy.
    /// </para>
    /// <para>
    /// The credential carries no username of its own, so it keeps taking the host's — which is what the
    /// connection that just succeeded did. Copying the resolved username into it would pin whatever the
    /// group happened to say at this moment, and quietly stop following the group afterwards.
    /// </para>
    /// <para>
    /// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
    /// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
    /// was abandoned, and a cancellation must not report it as cancelled.
    /// </para>
    /// </remarks>
    private async Task RememberTypedPasswordAsync(
        HostRowViewModel row,
        HostAuthentication authentication,
        CancellationToken cancellationToken)
    {
        // The password as dialled, not as the box currently reads: the two can differ by now, because a
        // handshake takes time and the box stays typeable throughout it.
        if (!RemembersConnectPassword
            || row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
            || authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
                + "newer version of DodoSSH, and binding a credential would re-encode it.";
            return;
        }

        var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };

        try
        {
            var credentialId = await session.Credentials
                .CreateAsync(row.VaultId, credential, cancellationToken)
                .ConfigureAwait(true);

            // Into the same vault as the host, deliberately: a credential in the personal vault bound to a
            // team's host is a binding every other member can see and none of them can resolve.
            await session.Hosts
                .UpdateAsync(
                    row.VaultId,
                    row.EntityId,
                    row.Host with { CredentialId = credentialId, AsksForPassword = null },
                    cancellationToken)
                .ConfigureAwait(true);
        }
        catch (Exception exception)
        {
            Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
            return;
        }

        // Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
        // left behind would apply to the next host somebody selects.
        RemembersConnectPassword = false;
        ConnectPassword = string.Empty;

        await ReloadAsync(cancellationToken).ConfigureAwait(true);

        Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
            + "will not be asked for again.";

        await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>The address as actually dialled.</summary>
    /// <remarks>
    /// Built from what was dialled rather than from the host's own fields, because neither half of it need
    /// come from the host. A bound credential can supply the username, and a group can supply the port and
    /// the username both — so a host saved with neither still has both here, and they are the ones the
    /// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
    /// naming a port nothing dialled is worse than no log.
    /// </remarks>
    private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
        string.Create(
            CultureInfo.InvariantCulture,
            $"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}");

    /// <summary>
    /// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Rate-limited, because pruning writes.</b> Log entries are synced items, so removing one is a real
    /// tombstone that goes to the server — and a prune on every sync tick would be a machine that writes to
    /// a server once a minute for ever. It rides the existing loop rather than a timer of its own, so a
    /// laptop that is asleep prunes nothing and one that is awake prunes when it was going to talk to the
    /// server anyway.
    /// </para>
    /// <para>
    /// The first pass after a vault opens always runs, which is what makes a machine that has been off for a
    /// month tidy up as soon as it comes back.
    /// </para>
    /// <para>
    /// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault
    /// somebody needs to be told about mid-sync.
    /// </para>
    /// </remarks>
    private async Task PruneLogsIfDueAsync(CancellationToken cancellationToken)
    {
        var now = TimeProvider.System.GetUtcNow();

        if (lastPruned is { } previous && now - previous < PruneInterval)
        {
            return;
        }

        lastPruned = now;

        try
        {
            await LogPruner.PruneAsync(session, LogRetention.Default, now, cancellationToken)
                .ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            // Locking, or closing.
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Housekeeping. Nothing the user did failed, and there is nothing for them to do about it.
        }
    }

    /// <summary>Records a connection that never became a session.</summary>
    /// <remarks>
    /// Start and end are the same instant, which is what a connection that never opened actually looks like:
    /// the duration is zero and the outcome carries the meaning.
    /// </remarks>
    private void RecordFailure(
        HostRowViewModel row,
        HostAuthentication authentication,
        ConnectionOutcome outcome)
    {
        var at = TimeProvider.System.GetUtcNow();

        connectionLog?.Record(
            Dialled(row, authentication),
            row.Label,
            row.EntityId,
            ConnectionKind.Terminal,
            at,
            at,
            outcome);
    }

    /// <summary>
    /// Everything the SSH stack needs to authenticate as somebody on a host.
    /// </summary>
    /// <param name="Username">The account to log in as, after any credential has had its say.</param>
    /// <param name="Credential">What proves it.</param>
    /// <remarks>
    /// The two travel together because a credential can change both. Returning only the secret and reading the
    /// username off the host separately is what the connect path used to do, and it would have sent a stored
    /// credential's password under the host's username — which is the one combination that is wrong in a way
    /// the server reports as "authentication failed".
    /// </remarks>
    private sealed record HostAuthentication(string Username, SshCredential Credential);

    /// <summary>
    /// Works out how a host authenticates, or says why it cannot.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
    /// <c>MemoryStream</c> — so the key reaches SSH.NET without ever becoming a file on disk. The
    /// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
    /// of a vault, and <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
    /// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
    /// </para>
    /// <para>
    /// False is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means the
    /// key or credential was deleted on another machine — plausible, and no reason to start sending a typed
    /// password to a host somebody deliberately set up not to accept one.
    /// </para>
    /// <para>
    /// The credential branch comes first because the two bindings are mutually exclusive and a host carrying
    /// both is already invalid; reading the credential first means a host that somehow acquired both is
    /// answered by the more specific of the two rather than by whichever the code happened to check.
    /// </para>
    /// </remarks>
    /// <summary>
    /// Works out how to reach a host, or says why it cannot.
    /// </summary>
    /// <remarks>
    /// The same resolution the Connect button performs, exposed because file transfer opens its own
    /// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
    /// username" would be a second place for a dangling binding to be silently turned back into a typed
    /// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
    /// transfers screen has its own box: they are different screens, and a password typed on one is not a
    /// password offered on the other.
    /// </remarks>
    internal bool TryBuildConnectionRequest(
        HostSecret host,
        string typedPassword,
        [NotNullWhen(true)] out SshConnectionRequest? request,
        [NotNullWhen(false)] out string? reason)
    {
        ArgumentNullException.ThrowIfNull(host);

        var resolved = Resolve(host);

        if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
        {
            request = null;
            return false;
        }

        request = new SshConnectionRequest(
            host.Hostname,
            resolved.Port.Value,
            authentication.Username,
            authentication.Credential);

        return true;
    }

    /// <summary>
    /// Turns a host into a key, a password or a prompt, or says why it cannot.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Takes the resolved host as well as the stored one, and this is where group context used to be
    /// lost.</b> Both callers of this and of <see cref="TryBuildConnectionRequest"/> used to hand over a
    /// bare <see cref="HostSecret"/>, which answers "what did the user type into this host" rather than
    /// "what happens when it is connected". It is the only authentication resolution in the product — both
    /// heads and both transports come through here — so a host inheriting its binding would otherwise have
    /// been offered a password prompt on every screen at once.
    /// </para>
    /// <para>
    /// The refusal messages name the group when the binding came from one. A user told that "this host
    /// authenticates with a key that is not in this keychain any more" would go looking at a host that says
    /// nothing about keys.
    /// </para>
    /// </remarks>
    private bool TryBuildAuthentication(
        HostSecret host,
        ResolvedHost resolved,
        string typedPassword,
        [NotNullWhen(true)] out HostAuthentication? authentication,
        [NotNullWhen(false)] out string? reason)
    {
        var binding = resolved.Binding;
        var where = binding.IsInherited ? $"'{host.Label}' inherits" : $"'{host.Label}' authenticates";
        var repair = binding.IsInherited
            ? "Edit the group it is filed under, or bind the host itself."
            : "Edit the host to choose another one, or set it back to a typed password.";

        if (binding is { Kind: ResolvedBindingKind.Credential, EntityId: { } credentialId })
        {
            if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
            {
                return Refuse(
                    $"{where} a credential that is not in this keychain any more. {repair}",
                    out authentication,
                    out reason);
            }

            // The credential's username wins where it has one, which is the whole reason it can carry one: one
            // account on twenty machines is described once. Falling back to the resolved username covers the
            // ordinary case of a shared password used under each machine's own account — and it is the
            // resolved one rather than the host's, so the fallback has three levels rather than two.
            return Complete(
                credential.Credential.Username ?? resolved.Username.Value,
                new SshPasswordCredential(credential.Credential.Password),
                out authentication,
                out reason);
        }

        if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId })
        {
            if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
            {
                return Refuse(
                    $"{where} an SSH key that is not in this keychain any more. {repair}",
                    out authentication,
                    out reason);
            }

            return Complete(
                resolved.Username.Value,
                new SshPrivateKeyCredential(
                    Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
                out authentication,
                out reason);
        }

        return Complete(
            resolved.Username.Value,
            new SshPasswordCredential(typedPassword),
            out authentication,
            out reason);
    }

    /// <summary>
    /// The last thing every branch has to agree on: there is somebody to log in as.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> because the answer depends
    /// on which branch was taken — a host with no username of its own is perfectly usable through a credential
    /// that carries one, and refusing it up front would have made the credential's most useful property
    /// unreachable.
    /// </para>
    /// <para>
    /// It is also why every caller passes a username the group chain has already been consulted for. Refusing
    /// before the chain is walked would refuse exactly the hosts inheritance exists to serve: the twenty
    /// machines filed under one group that says <c>deploy</c> once.
    /// </para>
    /// </remarks>
    private static bool Complete(
        string? username,
        SshCredential credential,
        out HostAuthentication? authentication,
        [NotNullWhen(false)] out string? reason)
    {
        if (string.IsNullOrEmpty(username))
        {
            return Refuse(
                "This host has no username. Edit it and add one, or bind it to a credential that carries one.",
                out authentication,
                out reason);
        }

        authentication = new HostAuthentication(username, credential);
        reason = null;
        return true;
    }

    private static bool Refuse(
        string reason,
        out HostAuthentication? authentication,
        out string? refusal)
    {
        authentication = null;
        refusal = reason;
        return false;
    }

    private HostSecret BuildHost() =>
        new()
        {
            Label = EditorLabel.Trim(),
            Hostname = EditorHostname.Trim(),

            // Empty means "take the group's" in both directions, which is what makes the placeholder honest:
            // what the box showed while empty is what the host will use. A relay host is the exception —
            // HostSecret.TryValidate refuses one with no port of its own, so the box is pre-filled and
            // required there; see EditorRelayEnabled.
            Port = EditorRelayEnabled ? EditorPort ?? HostSecret.DefaultPort : EditorPort,
            Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
            Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
            RelayEnabled = EditorRelayEnabled,

            // Only ever true or null, never false: the two would mean the same thing, and writing false would
            // change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
            //
            // And only for a host in a group, because only then was there another entry to choose instead —
            // an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
            // absence of a decision rather than one, and storing it as a decision would pin every ungrouped
            // host in the vault the first time it was edited.
            AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
                && EditorSelectedGroup?.EntityId is not null
                    ? true
                    : null,

            // The picker's set, and it is the picker's rather than the chips' — see editorTagIds. An id the
            // vault cannot currently offer, because the tag was deleted on another machine between this
            // editor opening and Save, is carried through rather than dropped by an edit that was about
            // something else.
            TagIds = editorTagIds,

            // Both read off the one picker, including the id of something that has gone missing. Reading them
            // from the picker rather than carrying the originals through is what lets a binding be removed at
            // all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
            // control means the two can never both be set: mutual exclusion by construction, rather than
            // HostSecret.TryValidate catching it after the fact.
            SshKeyId = Bound(AuthenticationKind.SshKey),
            CredentialId = Bound(AuthenticationKind.Credential),

            // Read off the picker for the same reason, including the id of a group that has gone missing:
            // an unrelated edit must not unfile a host as a side effect.
            GroupId = EditorSelectedGroup?.EntityId,
        };

    /// <summary>The picker's selection, if it names something of this kind.</summary>
    private Guid? Bound(AuthenticationKind kind) =>
        EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;

    /// <summary>
    /// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
    /// </summary>
    /// <param name="boundKeyId">The key the host names, if any.</param>
    /// <param name="boundCredentialId">The credential the host names, if any.</param>
    /// <param name="asksForPassword">Whether the host is pinned to a typed password.</param>
    /// <param name="grouped">Whether the host is filed under a group, and so has anything to inherit.</param>
    /// <remarks>
    /// <para>
    /// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped.
    /// Without one the picker would open on "Password (ask each time)", and someone editing the host's port
    /// would convert it to a typed password by saving — which is the quiet version of the failure the connect
    /// path refuses outright.
    /// </para>
    /// <para>
    /// <b>Four entries where there were three, and only for a host in a group.</b> The three states two
    /// nullable ids could carry became four when naming neither came to mean "inherit"; see
    /// <see cref="AuthenticationKind.Inherited"/>. For an ungrouped host the fourth would behave exactly like
    /// the first, so it is left out rather than offered and then explained.
    /// </para>
    /// </remarks>
    private void BuildAuthenticationChoices(
        Guid? boundKeyId,
        Guid? boundCredentialId,
        bool asksForPassword,
        bool grouped)
    {
        EditorAuthenticationChoices.Clear();
        EditorAuthenticationChoices.Add(AuthenticationChoice.Typed);

        if (grouped)
        {
            EditorAuthenticationChoices.Add(AuthenticationChoice.Inherited);
        }

        foreach (var key in Keys)
        {
            EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
        }

        foreach (var credential in Credentials)
        {
            EditorAuthenticationChoices.Add(
                AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
        }

        // At most one of the two is set on a valid host, so at most one placeholder is ever added.
        AddMissing(AuthenticationKind.SshKey, boundKeyId);
        AddMissing(AuthenticationKind.Credential, boundCredentialId);

        EditorSelectedAuthentication =
            Selected(boundKeyId, boundCredentialId, asksForPassword, grouped);
    }

    private void AddMissing(AuthenticationKind kind, Guid? boundId)
    {
        if (boundId is { } bound
            && !EditorAuthenticationChoices.Any(choice => choice.Kind == kind && choice.EntityId == bound))
        {
            EditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
        }
    }

    /// <remarks>
    /// <para>
    /// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the
    /// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a
    /// key when the host named a credential the day the two ever shared an id.
    /// </para>
    /// <para>
    /// The last two arms are where the third state has to be told from the fourth. A host that names neither
    /// binding and does not ask for a password is inheriting; one that asks is not. An ungrouped host has no
    /// "Inherit" entry to select, so it falls to the typed one — which is what it resolves to anyway.
    /// </para>
    /// </remarks>
    private AuthenticationChoice Selected(
        Guid? boundKeyId,
        Guid? boundCredentialId,
        bool asksForPassword,
        bool grouped) =>
        (boundKeyId, boundCredentialId) switch
        {
            ({ } key, _) => Find(AuthenticationKind.SshKey, key),
            (_, { } credential) => Find(AuthenticationKind.Credential, credential),
            _ when asksForPassword || !grouped => AuthenticationChoice.Typed,
            _ => AuthenticationChoice.Inherited,
        };

    private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) =>
        EditorAuthenticationChoices
            .FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
        ?? AuthenticationChoice.Typed;

    /// <summary>Fills the group picker, keeping whatever the host is currently filed under selectable.</summary>
    /// <param name="groupId">The group the host names, if any.</param>
    /// <remarks>
    /// A group that is no longer in the vault gets a placeholder, for the reason
    /// <see cref="BuildAuthenticationChoices"/> gives: without one the picker would open on "No group", and
    /// somebody editing the host's port would unfile it by saving. It says the group is gone rather than
    /// naming it, because there is nothing left to read the name off.
    /// </remarks>
    private void BuildGroupChoices(Guid? groupId)
    {
        EditorGroupChoices.Clear();
        EditorGroupChoices.Add(GroupChoice.None);

        foreach (var group in Groups)
        {
            EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label));
        }

        if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
        {
            EditorGroupChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
        }

        EditorSelectedGroup = EditorGroupChoices.FirstOrDefault(choice => choice.EntityId == groupId)
            ?? GroupChoice.None;
    }

    private CredentialSecret BuildCredential() =>
        new()
        {
            Label = CredentialEditorLabel.Trim(),

            // Not trimmed and not emptied, exactly as a key's passphrase is not: leading or trailing spaces
            // are legitimate in a password, and CredentialSecret refuses an empty one on its own.
            Password = CredentialEditorPassword,

            // Trimmed, unlike the password. A username with a trailing space is a different account name to
            // sshd, and it is never the one somebody meant.
            Username = CredentialEditorUsername.Trim(),
            Notes = string.IsNullOrWhiteSpace(CredentialEditorNotes) ? null : CredentialEditorNotes,
        };

    /// <remarks>
    /// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
    /// would eventually tidy a format it did not fully understand — the same reason
    /// <c>SshKeySecret.PrivateKeyPem</c> stores it verbatim. Everything else is trimmed, because a label
    /// with a trailing space sorts oddly and reads as a different name.
    /// </remarks>
    private SshKeySecret BuildKey() =>
        new()
        {
            Label = KeyEditorLabel.Trim(),
            PrivateKeyPem = KeyEditorPrivateKey,

            // Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and
            // the record turns an empty one into null on its own.
            Passphrase = KeyEditorPassphrase,
            PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(),
            Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes,
        };

    /// <summary>
    /// Whether the host editor has to be dealt with before the sidebar starts another one.
    /// </summary>
    /// <remarks>
    /// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
    /// <c>HostSidebar</c>, on the Hosts screen, and nothing on the Vault screen shares its column or its
    /// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start
    /// editing a host — the two cannot even be looked at at the same time — so this no longer asks about
    /// them. See <see cref="AVaultEditorIsInTheWay"/> for the reasoning this once shared with them, and why
    /// splitting it was necessary rather than cosmetic: the earlier single check refused every host action
    /// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an
    /// editor the user could not see and no way to reach it without abandoning what they had just started
    /// on the Hosts screen.
    /// </remarks>
    private bool AHostEditorIsInTheWay()
    {
        if (IsEditing)
        {
            Status = "Finish or cancel the host you are editing first.";
        }

        return IsEditing;
    }

    /// <summary>
    /// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
    /// </summary>
    /// <remarks>
    /// <para>
    /// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted
    /// private key in a bound string for as long as it is open, and only <c>CancelKeyEdit</c> lets go of
    /// it. Letting the rail move the category, or another editor open, with that editor still holding
    /// material would leave a private key in a form nobody can see, with nothing on screen to say it is
    /// there.
    /// </para>
    /// <para>
    /// Refused rather than resolved by closing the open editor, because closing it would silently discard
    /// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
    /// else. One sentence and one click is the cheaper of the two.
    /// </para>
    /// <para>
    /// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
    /// see <see cref="AHostEditorIsInTheWay"/> — and asking about it here is what used to leave three
    /// quarters of this screen inert with a status line pointing at an editor the user was not looking at.
    /// </para>
    /// </remarks>
    private bool AVaultEditorIsInTheWay()
    {
        Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore, IsEditingTag)
            switch
        {
            (true, _, _, _, _) => "Finish or cancel the SSH key you are editing first.",
            (_, true, _, _, _) => "Finish or cancel the credential you are editing first.",
            (_, _, true, _, _) => "Finish or cancel the key you are generating first.",
            (_, _, _, true, _) => "Finish or cancel the bucket you are editing first.",
            (_, _, _, _, true) => "Finish or cancel the tag you are editing first.",
            _ => Status,
        };

        return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore
            || IsEditingTag;
    }

    private void ClearKeyEditor()
    {
        KeyEditorLabel = string.Empty;
        KeyEditorPrivateKey = string.Empty;
        KeyEditorPassphrase = string.Empty;
        KeyEditorPublicKey = string.Empty;
        KeyEditorNotes = string.Empty;
    }

    private void ClearCredentialEditor()
    {
        CredentialEditorLabel = string.Empty;
        CredentialEditorUsername = string.Empty;
        CredentialEditorPassword = string.Empty;
        CredentialEditorNotes = string.Empty;
    }

    private async Task LoadConflictsAsync(CancellationToken cancellationToken)
    {
        var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);

        Conflicts.Clear();

        foreach (var notice in notices)
        {
            Conflicts.Add(new ConflictRowViewModel(notice));
        }

        OnPropertyChanged(nameof(HasConflicts));
    }

    /// <remarks>
    /// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
    /// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
    /// of recording those is that somebody sees them.
    /// </remarks>
    /// <remarks>
    /// Movement and attention only — deliberately not failure. A background pass that announced every
    /// unreachable vault would be a socket error on screen once a minute, which is the thing
    /// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
    /// <see cref="LastSyncFailed"/> instead, and the titlebar stops claiming to be up to date. Pressing
    /// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
    /// </remarks>
    /// <remarks>
    /// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
    /// action's own status message, and this machine reads its own entries back on the next pull — so a
    /// rule written against the raw numbers would overwrite that message after every single save, which is
    /// exactly what it did until the report learned to tell the two apart.
    /// </remarks>
    private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
        reports.Any(vault => vault.Succeeded
            && (vault.Report!.PulledItems > 0
                || vault.Report.PushedItems > 0
                || vault.Report.NeedsAttention

                // A pass that had to start over says so even when it pulled nothing, which is the one
                // place this rule is broken deliberately. A machine that silently re-read a whole vault
                // has had something happen to it, and the alternative is that nobody ever finds out.
                || vault.Report.ResyncedFromStart));

    /// <remarks>
    /// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
    /// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
    /// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
    /// costs nothing.
    /// </remarks>
    private static string Describe(IReadOnlyList<VaultSyncReport> reports)
    {
        var failed = reports
            .Where(vault => !vault.Succeeded)
            .Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
            .ToList();

        var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();

        var line = succeeded.Count switch
        {
            0 => string.Empty,
            1 => Describe(succeeded[0]),
            _ => DescribeMany(succeeded),
        };

        if (failed.Count == 0)
        {
            return line.Length == 0 ? "Nothing to synchronise." : line;
        }

        var names = string.Join("; ", failed);

        return line.Length == 0
            ? $"Could not synchronise {names}."
            : $"{line} Could not synchronise {names}.";
    }

    private static string DescribeMany(List<SyncReport> reports)
    {
        var pulled = reports.Sum(report => report.Pulled);
        var pushed = reports.Sum(report => report.Pushed);
        var attention = reports.Count(report => report.NeedsAttention);

        var line = pulled == 0 && pushed == 0
            ? $"Already up to date across {reports.Count} vaults."
            : $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";

        return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
    }

    private static string Describe(SyncReport report)
    {
        // Said first, and in both branches, because it is the explanation for the numbers after it. A pass
        // reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong;
        // this is what actually happened, and it needs nothing from the reader.
        var replayed = report.ResyncedFromStart
            ? "The server no longer recognised this machine's position, so the keychain was read again from "
                + "the beginning. "
            : string.Empty;

        if (!report.NeedsAttention)
        {
            return replayed + (report.Pulled == 0 && report.Pushed == 0
                ? "Already up to date."
                : $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
        }

        var notes = new List<string>();

        // "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
        // would send someone looking through the wrong list for something that was not there.
        if (report.Resurrected > 0)
        {
            notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
        }

        if (report.DeletesAbandoned > 0)
        {
            notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
        }

        if (report.Parked > 0)
        {
            notes.Add($"{report.Parked} change(s) were refused and need attention");
        }

        if (report.Unreadable > 0)
        {
            notes.Add($"{report.Unreadable} item(s) could not be decrypted");
        }

        if (report.RekeyRequired)
        {
            notes.Add("this keychain was rekeyed and your access needs re-issuing");
        }

        return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
    }

    private async Task RunAsync(string busyMessage, Func<Task> work)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;
        Status = busyMessage;

        try
        {
            await work().ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            Status = "Cancelled.";
        }
        catch (Exception exception)
        {
            Status = exception.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    partial void OnSelectedHostChanged(HostRowViewModel? value)
    {
        OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
        OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
        OnPropertyChanged(nameof(ShowsConnectBar));

        // The drawer opens on a selection and closes when there is none, so both of these move with it.
        OnPropertyChanged(nameof(IsDrawerOpen));
        OnPropertyChanged(nameof(IsShowingHostDetail));

        // Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
        // one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
        // other.
        SelectedSidebarRow = value;

        DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
    }

    /// <remarks>
    /// <para>
    /// The one direction that needs a decision. A host selection is the application's selection and passes
    /// straight through; a heading is not, and is turned back into whatever was selected before it, so that
    /// clicking a group name neither breaks the buttons at the foot of the sidebar nor leaves a row
    /// highlighted that none of them act on.
    /// </para>
    /// <para>
    /// A null is left alone rather than cleared through. It arrives from the <c>ListBox</c>'s own answer to
    /// the <c>Reset</c> that rebuilding the list raises — which happens on every filter keystroke and every
    /// background sync — and treating that as the user deselecting would take the selection away from under
    /// them once a minute. Deliberate clearing is done by <see cref="RebuildVisibleHosts"/>, which sets
    /// <see cref="SelectedHost"/> itself.
    /// </para>
    /// </remarks>
    partial void OnSelectedSidebarRowChanged(ISidebarRow? value)
    {
        switch (value)
        {
            case HostRowViewModel host:
                SelectedHost = host;
                break;

            case SidebarGroupHeader:
                SelectedSidebarRow = SelectedHost;
                break;

            default:
                break;
        }
    }

    partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
    {
        DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
    }

    /// <remarks>
    /// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
    /// property. Assigning the same value again is a no-op, so this and
    /// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
    /// </remarks>
    partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
    {
        SelectedGroup = value;

        OnPropertyChanged(nameof(IsFilteredByGroup));

        RebuildVisibleHosts();
    }

    partial void OnPendingDeletionChanged(DeletionRequest? value)
    {
        OnPropertyChanged(nameof(IsConfirmingDeletion));
        OnPropertyChanged(nameof(IsConfirmingHostDeletion));
        OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
        OnPropertyChanged(nameof(ShowsHostActions));
        OnPropertyChanged(nameof(ShowsGroupActions));
        OnPropertyChanged(nameof(ShowsItemActions));
    }

    partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel));

    /// <summary>
    /// Takes the question away when the selection it was asked about has moved on.
    /// </summary>
    /// <remarks>
    /// Compared by entity id rather than by row, and that is the whole point of the method. A reload
    /// replaces every row object in the list, so a background pass a minute after the question would
    /// otherwise take the card away from under somebody still reading it — while a click onto a different
    /// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
    /// nobody is looking at any more.
    /// </remarks>
    private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
    {
        if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
        {
            PendingDeletion = null;
        }
    }

    /// <remarks>
    /// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
    /// hosts already in memory, with no decryption and nothing on disk behind it.
    /// </remarks>
    partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();

    /// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
    /// <remarks>
    /// Its own method purely for length: <see cref="RebuildVaultItems"/> is one <c>if</c> per kind and a
    /// fifth took it past what this project lets a method be. Split at the newest arm rather than the
    /// prettiest place, so the diff that added it is the diff that moved it.
    /// </remarks>
    private void AddTagRows()
    {
        if (Section is not (VaultSection.All or VaultSection.Tags))
        {
            return;
        }

        foreach (var tag in Tags)
        {
            VaultItems.Add(new VaultItemRowViewModel(
                VaultItemKind.Tag,
                tag.EntityId,
                tag.Label,
                "TAG",
                tag.Description,
                tag.Badge,
                tag.HasUnsyncedChanges));
        }
    }

    /// <summary>
    /// Refills the vault table from the typed lists.
    /// </summary>
    /// <remarks>
    /// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
    /// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
    /// them would put a pin nobody created between two things somebody did.
    /// </remarks>
    private void RebuildVaultItems()
    {
        var selectedId = SelectedVaultItem?.EntityId;

        VaultItems.Clear();

        if (Section is VaultSection.All or VaultSection.Keys)
        {
            foreach (var key in Keys)
            {
                VaultItems.Add(new VaultItemRowViewModel(
                    VaultItemKind.Key,
                    key.EntityId,
                    key.Label,
                    "SSH KEY",
                    key.Description,
                    key.Badge,
                    key.HasUnsyncedChanges));
            }
        }

        if (Section is VaultSection.All or VaultSection.Credentials)
        {
            foreach (var credential in Credentials)
            {
                VaultItems.Add(new VaultItemRowViewModel(
                    VaultItemKind.Credential,
                    credential.EntityId,
                    credential.Label,
                    "PASSWORD",
                    credential.Description,
                    credential.Badge,
                    credential.HasUnsyncedChanges));
            }
        }

        AddTagRows();

        if (Section is VaultSection.All or VaultSection.Buckets)
        {
            foreach (var store in ObjectStores)
            {
                VaultItems.Add(new VaultItemRowViewModel(
                    VaultItemKind.ObjectStore,
                    store.EntityId,
                    store.Label,
                    "BUCKET",
                    store.Description,
                    store.Badge,
                    store.HasUnsyncedChanges));
            }
        }

        // The selection survives a reload, as every other list's does, and for the same reason: a background
        // sync every minute would otherwise move the detail pane out from under whoever was reading it.
        SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);

        OnPropertyChanged(nameof(SectionSummary));
        OnPropertyChanged(nameof(HasVaultItems));
        OnPropertyChanged(nameof(TotalItemCount));
        OnPropertyChanged(nameof(EmptySectionMessage));
    }

    /// <remarks>
    /// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving
    /// the other two alone means switching category and back does not clear what an editor was pointing at.
    /// </remarks>
    partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value)
    {
        OnPropertyChanged(nameof(HasSelectedVaultItem));
        OnPropertyChanged(nameof(SelectedItemIsEditable));
        OnPropertyChanged(nameof(SelectedItemIsKey));
        OnPropertyChanged(nameof(SelectedDetailHeading));
        OnPropertyChanged(nameof(ShowsItemActions));

        // Every kind this table can delete, because one selection covers all of their lists.
        DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
        DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
        DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId);
        DisarmIfAimedElsewhere(DeletionTarget.Tag, value?.EntityId);

        switch (value?.Kind)
        {
            case VaultItemKind.Key:
                SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId);
                break;

            case VaultItemKind.Credential:
                SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
                break;

            case VaultItemKind.ObjectStore:
                SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId);
                break;

            case VaultItemKind.Tag:
                SelectedTag = Tags.FirstOrDefault(row => row.EntityId == value.EntityId);
                break;

            default:
                break;
        }
    }

    /// <remarks>
    /// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
    /// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
    /// the DELETE the user could see belonged to an item they were no longer looking at.
    /// </remarks>
    partial void OnIsEditingTagChanged(bool value) => DisarmOnceAnEditorIsOpen(value);

    partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));

    partial void OnUnreadableItemsChanged(int value)
    {
        OnPropertyChanged(nameof(HasUnreadableItems));
        OnPropertyChanged(nameof(UnreadableSummary));
    }

    /// <remarks>
    /// Both, on every change. A selector that highlights the showing section and a column that shows the
    /// selected one are the same fact read from two directions, and raising only the one that became true
    /// would leave the other button lit.
    /// </remarks>
    partial void OnSectionChanged(VaultSection value)
    {
        OnPropertyChanged(nameof(ShowsAll));
        OnPropertyChanged(nameof(ShowsKeys));
        OnPropertyChanged(nameof(ShowsCredentials));
        OnPropertyChanged(nameof(ShowsBuckets));
        OnPropertyChanged(nameof(ShowsTags));
        OnPropertyChanged(nameof(SectionTitle));

        RebuildVaultItems();
    }

    /// <remarks>
    /// The editing id is set before this flips in every path that opens the editor, and cleared after it
    /// flips back in every path that closes one, so this notification always observes the pair in a
    /// consistent state.
    /// </remarks>
    partial void OnIsEditingChanged(bool value)
    {
        OnPropertyChanged(nameof(CanForgetHostKey));
        OnPropertyChanged(nameof(ShowsHostActions));

        DisarmOnceAnEditorIsOpen(value);
    }

    partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);

    partial void OnIsGeneratingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);

    partial void OnGenerateAlgorithmChanged(SshKeyAlgorithm value)
    {
        OnPropertyChanged(nameof(GeneratesEd25519));
        OnPropertyChanged(nameof(GeneratesRsa));
    }

    partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);

    partial void OnIsEditingObjectStoreChanged(bool value) => DisarmOnceAnEditorIsOpen(value);

    /// <summary>
    /// Takes the question away when an editor opens over the pane it was asked in.
    /// </summary>
    /// <remarks>
    /// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
    /// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
    /// rule for both, rather than a guard on the three commands that would have to be remembered by the
    /// fourth.
    /// </remarks>
    private void DisarmOnceAnEditorIsOpen(bool opened)
    {
        if (opened)
        {
            PendingDeletion = null;
        }
    }

    partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
        OnPropertyChanged(nameof(HasPendingHostKey));

    partial void OnHostKeyMismatchChanged(string? value) =>
        OnPropertyChanged(nameof(HasHostKeyMismatch));
}
