using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>One vault, as a switch in the rail's user popover.</summary>
/// <remarks>
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
/// of a fact the cache already holds.
/// </remarks>
/// <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>
/// <param name="IsShown">Whether its items are currently drawn.</param>
internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPersonal, bool IsShown)
{
    /// <summary>What the switch says.</summary>
    /// <remarks>
    /// A shared vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
    /// version of the same reason: two vaults may hold a host with the same label, and which vault a switch
    /// is about is the only thing that tells the two switches apart.
    /// </remarks>
    internal string Display => IsPersonal ? Name : $"{Name} · SHARED";

    /// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
    /// <remarks>
    /// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
    /// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
    /// letter is the honest half of the same badge.
    /// </remarks>
    internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";

    /// <summary>Whether this vault can be switched off.</summary>
    /// <remarks>
    /// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
    /// the one the group and tag editors write to, and the fallback the save-target picker lands on — so
    /// switching it off would empty half the application rather than filter it. It is still drawn, ticked,
    /// because a vault missing from a list of vaults reads as something having gone wrong.
    /// </remarks>
    internal bool CanHide => !IsPersonal;
}

/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState
{
    /// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
    Starting = 0,

    /// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
    NeedsServer = 1,

    /// <summary>Signed in, but the account has no vault key yet.</summary>
    NeedsEnrollment = 2,

    /// <summary>
    /// Showing the recovery code, and refusing to move on until the user confirms they have it.
    /// </summary>
    /// <remarks>
    /// A separate state rather than a dismissible banner, because this is the only moment the code exists.
    /// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
    /// reset by design — so this is the one screen a user must not be able to click past.
    /// </remarks>
    ShowingRecoveryCode = 3,

    /// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
    Locked = 4,

    /// <summary>Open.</summary>
    Unlocked = 5,
}

/// <summary>
/// Which of the unlocked application's screens the nav rail is pointing at.
/// </summary>
/// <remarks>
/// <para>
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
/// </para>
/// </remarks>
internal enum ShellScreen
{
    /// <summary>The host list, which is where the application opens.</summary>
    Hosts = 0,

    /// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
    Transfers = 1,

    /// <summary>Everything in the open vault that is not a host: keys, passwords, buckets, tags.</summary>
    /// <remarks>
    /// Named for what the rail calls it rather than for the vault it reads, which is what it was called
    /// when <see cref="Vaults"/> arrived beside it. Two members a letter apart, one meaning "one vault's
    /// contents" and the other "the vaults themselves", is a pair somebody eventually gets the wrong way
    /// round.
    /// </remarks>
    Keychain = 2,

    /// <summary>The vaults themselves and the people in them. Both heads draw it.</summary>
    /// <remarks>
    /// Was <c>Team</c>, and the value is unchanged with it: the screen is the same destination, and these
    /// numbers are written into <c>NavRail.axaml</c> as <c>x:Static</c> literals. What changed is what the
    /// screen is about — see <see cref="VaultsViewModel"/>.
    /// </remarks>
    Vaults = 3,

    /// <summary>Preferences.</summary>
    Preferences = 4,

    /// <summary>The host keys this keychain has approved.</summary>
    /// <remarks>
    /// Appended rather than slotted in beside the keychain screen it came out of. These values are written
    /// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
    /// silent change to what every one of those means.
    /// </remarks>
    KnownHosts = 5,

    /// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
    /// <remarks>
    /// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
    /// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
    /// something almost nobody is looking at.
    /// </remarks>
    Import = 6,

    /// <summary>The saved commands in this keychain.</summary>
    /// <inheritdoc cref="KnownHosts" path="/remarks" />
    Snippets = 7,

    /// <summary>What has been connected to, and what has been changed.</summary>
    /// <inheritdoc cref="KnownHosts" path="/remarks" />
    Logs = 8,

    /// <summary>
    /// The phone's hub for everything the bottom bar has no room for.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Drawn by the Android head alone. The desktop has a nav rail with room for every destination, so it has
    /// nothing to put behind a hub and never sets this; the v2 phone design has four slots and nine places to
    /// go, so five of them live one tap deeper. It is a member of the shared enum rather than a phone-local
    /// bool because it is a value of <see cref="Screen"/> like any other — the back arrow on each of those
    /// five screens comes here, and a second notion of "where am I" is how the two would disagree.
    /// </para>
    /// <inheritdoc cref="KnownHosts" path="/remarks" />
    /// </remarks>
    More = 9,

    /// <summary>
    /// Objects in an S3-compatible bucket.
    /// </summary>
    /// <remarks>
    /// The same screen as <see cref="Transfers"/> over the same <see cref="TransfersViewModel"/>, and a
    /// separate destination anyway. What differs is only which picker is offered, so the two do not each
    /// need a screen — but they do each need a name, because the v2 design puts SFTP and S3 side by side in
    /// the hub, and a single "files" entry that silently remembered which kind you last opened would be a
    /// destination whose meaning depended on history. Entering either sets
    /// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
    /// </remarks>
    Buckets = 10,
}

/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
    /// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
    Page = 0,

    /// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
    Terminal = 1,
}

/// <summary>
/// Which page the settings mode is showing, while <see cref="MainWindowViewModel.ActiveSettingsPage"/> is
/// not null.
/// </summary>
/// <remarks>
/// <para>
/// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page
/// area with its own — see <c>SettingsView.axaml</c> and the settings-mode remark on
/// <see cref="MainWindowViewModel.ActiveSettingsPage"/>. This is a second, orthogonal notion of "where am I"
/// from <see cref="ShellScreen"/>, not a replacement for it: <see cref="Preferences"/> and <see cref="Vaults"/>
/// still set <see cref="MainWindowViewModel.Screen"/> to the matching <see cref="ShellScreen"/> member, so
/// every existing binding and test that asks "is the screen Preferences" keeps its answer. <see cref="General"/>,
/// <see cref="Account"/> and <see cref="Security"/> have no <see cref="ShellScreen"/> counterpart — nothing
/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
/// </para>
/// <para>
/// <b>v5c-2: Groups and Tags joined.</b> The design's rail lists them beside Security and Preferences; v5c-1
/// omitted both from <c>SettingsView.axaml</c> rather than building a placeholder for either, and this wave
/// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a
/// <see cref="ShellScreen"/> counterpart: managing groups and tags has never been its own screen before this,
/// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding
/// for either to keep in step with.
/// </para>
/// </remarks>
internal enum SettingsPage
{
    /// <summary>Updates, and the refused items from the design's General page, as an essay.</summary>
    General = 0,

    /// <summary>The vaults themselves and the people in them — the existing <see cref="ShellScreen.Vaults"/> screen.</summary>
    Vaults = 1,

    /// <summary>The signed-in profile, the sign-in fact, and signing out of this machine.</summary>
    Account = 2,

    /// <summary>The end-to-end explainer, Windows Hello, and approved host keys.</summary>
    Security = 3,

    /// <summary>This machine's terminal and keychain settings — the existing <see cref="ShellScreen.Preferences"/> screen.</summary>
    Preferences = 4,

    /// <summary>Every group, and the hosts filed under each — the existing group commands, given their own page.</summary>
    Groups = 5,

    /// <summary>Every tag, and how many hosts wear each — the existing tag commands, given their own page.</summary>
    Tags = 6,
}

/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
/// <remarks>
/// <para>
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
/// browser sign-in; everything about the identity provider comes from
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
/// unlocks, which is the state the application spends nearly all of its life in.
/// </para>
/// <para>
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
/// </para>
/// </remarks>
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
    private readonly ClientPaths paths;
    private readonly ClientCacheFactory caches;
    private readonly TerminalWorkspace workspace;

    /// <remarks>
    /// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
    /// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
    /// it are this state machine's business. The same instance was handed to the connection factory when the
    /// application was composed.
    /// </remarks>
    private readonly VaultKnownHostStore knownHosts;

    /// <remarks>
    /// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
    /// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
    /// reports itself unavailable rather than a null this state machine would have to check for.
    /// </remarks>
    private readonly IDeviceKeyStore deviceKeys;

    private readonly SignInHandler signIn;

    /// <remarks>
    /// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
    /// only be online because somebody signed in during this run, which is what every test that asserts
    /// offline behaviour relies on.
    /// </remarks>
    private readonly ResumeHandler? resume;

    private readonly TimeProvider clock;
    private readonly Argon2Profile? passphraseProfile;

    /// <summary>What this machine is called — on the account, and on every log entry it writes.</summary>
    /// <remarks>
    /// From the head rather than from <see cref="Environment.MachineName"/>, because that property answers
    /// <c>localhost</c> on Android and would make every phone in an account indistinguishable from every
    /// other one — in the device list a user revokes from, and in the log they read to find out which
    /// machine opened a shell. The desktop passes nothing and keeps the machine name; a phone knows its own
    /// model and nothing in this assembly can ask for it, because <c>Android.OS.Build</c> is not reachable
    /// from a <c>net10.0</c> library. See docs/android-port.md §7.
    /// </remarks>
    private readonly string deviceName;

    /// <remarks>
    /// Built here from the paths rather than taken as a dependency, because it holds preferences and not
    /// state: there is nothing for a head to substitute, and a constructor parameter every head would pass
    /// the same value to is a parameter that only ever makes the heads longer.
    /// </remarks>
    private readonly ClientSettingsStore settings;

    /// <remarks>
    /// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
    /// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
    /// rather than one that fails silently — see <see cref="VaultViewModel"/>.
    /// </remarks>
    private readonly Func<string, Task>? copyToClipboard;

    /// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
    private readonly Action<Action> post;

    /// <remarks>
    /// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
    /// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
    /// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
    /// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
    /// </remarks>
    private readonly TransfersViewModel transfers;

    /// <summary>
    /// Where connections are recorded, for as long as a vault is open to record them into.
    /// </summary>
    /// <remarks>
    /// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
    /// and for the same reason: the thing that calls it — the workspace — outlives every lock.
    /// </remarks>
    private readonly ConnectionRecorder connectionLog;

    private readonly VaultsViewModel vaults;

    /// <summary>
    /// Where newer builds come from, and how far one has got.
    /// </summary>
    /// <remarks>
    /// A process-lifetime object like <see cref="transfers"/>, and for a reason that is its own rather than
    /// borrowed: this one outlives a lock because the release channel is not the vault.
    /// </remarks>
    private readonly UpdateViewModel updateScreen;

    /// <summary>
    /// The tab standing in for each connection that has been asked for and has not answered yet.
    /// </summary>
    /// <remarks>
    /// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
    /// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
    /// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
    /// when the user closes the tab out from under it.
    /// </remarks>
    private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];

    /// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
    /// <remarks>
    /// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
    /// ever names, so a tick any faster would restring a fact that has not changed in any word it prints.
    /// </remarks>
    private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);

    /// <summary>
    /// Whether the hosts screen was showing the last time <see cref="UpdateLastConnectedVisibility"/> ran.
    /// </summary>
    /// <remarks>
    /// The transition is what matters, not the level — see that method. Without this, every unrelated
    /// screen or surface change would re-read the connection log for no reason, which is exactly the
    /// background re-read <c>LogsViewModel.cs</c> argues a connection log must never be put on.
    /// </remarks>
    private bool wasHostsScreenShowing;

    /// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
    private CancellationTokenSource? lastConnectedTick;

    /// <summary>
    /// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
    /// </summary>
    /// <remarks>
    /// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
    /// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
    /// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
    /// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
    /// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
    /// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
    /// case nothing else does: sitting still on a connected screen while a minute passes.
    /// </remarks>
    private readonly CancellationTokenSource sessionElapsedTick = new();

    private IVaultServer? connection;

    /// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
    private string? rememberedToken;

    /// <summary>Guards against two resume attempts overlapping.</summary>
    /// <remarks>
    /// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
    /// the Sync button — and what has to be prevented is a second attempt starting while the first is
    /// waiting on a token endpoint, not a data race.
    /// </remarks>
    private bool resuming;

    private bool disposed;

    /// <summary>
    /// Where <see cref="LeaveSettings"/> goes back to — captured once, on the turn settings mode is
    /// entered, and not touched again until it is left.
    /// </summary>
    /// <remarks>
    /// Not re-captured on every <see cref="EnterSettings"/> call, which is what makes switching pages inside
    /// settings mode (Preferences, then Security, then Account) still come back to the one screen the user
    /// was actually on beforehand rather than to whichever settings page they last visited.
    /// </remarks>
    private ShellScreen settingsReturnScreen;

    /// <inheritdoc cref="settingsReturnScreen" />
    private ShellSurface settingsReturnSurface;

    /// <summary>
    /// Establishes a connection to a server.
    /// </summary>
    /// <remarks>
    /// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
    /// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
    /// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
    /// would be the wrong trade.
    /// </remarks>
    internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);

    /// <summary>
    /// Re-establishes a connection from a remembered sign-in, without a browser.
    /// </summary>
    /// <remarks>
    /// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
    /// and a token endpoint, and making that the only way to reach this state machine would put the whole
    /// "comes back online by itself" behaviour out of reach of a test.
    /// </remarks>
    internal delegate Task<IVaultServer> ResumeHandler(
        Uri serverUrl,
        string refreshToken,
        CancellationToken cancellationToken);

    /// <param name="sftpSessions">
    /// How file-transfer sessions are opened. The same object as the connection factory in the composed
    /// application — one type implements both — and a separate parameter because it is a separate capability
    /// and the tests that drive this state machine have no use for it.
    /// </param>
    /// <param name="deviceName">
    /// What to call this machine. Optional, and the default is right for every head that runs on a desktop
    /// operating system — see the field it is kept in for the one that it is not right for.
    /// </param>
    /// <param name="updates">
    /// Where newer builds of this client come from. Optional, and the default is a channel that reports
    /// itself unavailable — which is a deliberate difference from <paramref name="deviceKeys"/>, which every
    /// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the
    /// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
    /// the Android head's distribution separately, so it must never acquire one by accident.
    /// </param>
    /// <param name="post">
    /// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
    /// dispatcher, which is the answer in every real head.
    /// <para>
    /// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for exactly the reason
    /// <c>TransfersViewModel</c>'s is one — see the remark there. It is process-wide and belongs to whichever
    /// thread touched it first, so a suite that runs with no window has no way to drain it and no way to
    /// know whose it is. This one exists because connection phases are reported from the handshake's own
    /// thread, which is the first thing in this class that has to cross onto the UI thread and also has to
    /// be assertable: the three <c>Dispatcher.UIThread.Post</c> calls that predate it are the ones this
    /// suite's own comments record as out of reach, and they are left alone rather than swept in here.
    /// </para>
    /// </param>
    internal MainWindowViewModel(
        ClientPaths paths,
        ClientCacheFactory caches,
        TerminalWorkspace workspace,
        VaultKnownHostStore knownHosts,
        IDeviceKeyStore deviceKeys,
        SignInHandler signIn,
        TimeProvider clock,
        ISftpSessionFactory sftpSessions,
        Argon2Profile? passphraseProfile = null,
        ResumeHandler? resume = null,
        Func<string, Task>? copyToClipboard = null,
        string? deviceName = null,
        IUpdateChannel? updates = null,
        Action<Action>? post = null)
    {
        this.post = post ?? (action => Dispatcher.UIThread.Post(action));

        this.paths = paths;
        this.caches = caches;
        this.workspace = workspace;
        this.knownHosts = knownHosts;
        this.deviceKeys = deviceKeys;
        this.signIn = signIn;
        this.resume = resume;
        this.clock = clock;
        this.passphraseProfile = passphraseProfile;
        this.copyToClipboard = copyToClipboard;

        // Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
        // blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
        // to register for a reason no message could explain.
        this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName;

        // The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
        // keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
        // to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
        transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);

        // Built once, like the workspace it writes for, and given a vault only while one is open. It has to
        // outlive every lock for the same reason the workspace does: a shell opened before a lock is still
        // running after it, and the entry it eventually produces belongs to the vault it was made in.
        connectionLog = new ConnectionRecorder(clock, this.deviceName);
        this.workspace.ConnectionLog = connectionLog;

        // Both dependencies as functions rather than values: the connection arrives after sign-in and the
        // session after unlock, and both go away again on lock. Capturing either would give this screen a
        // reference that outlives what it points at — which for a session means holding vault keys past the
        // moment locking is supposed to have zeroed them.
        // The third argument is how a vault made over there reaches the lists and the menu over here: both
        // are built from the session's vault list, and neither would otherwise learn that it had grown until
        // something else happened to rebuild them.
        vaults = new VaultsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);

        // Subscribed for the life of the process, because the workspace lives that long and so does the tab
        // list. Detached in DisposeAsync, which is the only point either of them ends.
        this.workspace.SessionEnded += OnWorkspaceSessionEnded;
        this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
        this.workspace.RendererReattached += OnRendererReattached;

        settings = new ClientSettingsStore(paths);

        updateScreen = CreateUpdateScreen(updates);

        ApplyStoredPreferences();

        _ = TellRendererTheFontSizeAsync();

        StartSessionShellTracking();
    }

    /// <summary>
    /// Takes this machine's own preferences off disk, before anything can read them.
    /// </summary>
    /// <remarks>
    /// Read straight away rather than at first use, and both of them for the same reason: whatever is stored
    /// is what the first window draws. A phone builds its terminal's font buttons from the size, and the
    /// session shell decides whether to give a sidebar 300 pixels — either arriving a moment later shows as
    /// the interface correcting itself in front of the user.
    /// </remarks>
    private void ApplyStoredPreferences()
    {
        var stored = settings.Read();

        TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize);
        IsSessionSidebarOpen = stored.SessionSidebarOpen;
    }

    /// <summary>
    /// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
    /// </summary>
    /// <remarks>
    /// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
    /// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
    /// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
    /// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
    /// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
    /// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
    /// </remarks>
    private void StartSessionShellTracking()
    {
        transfers.PropertyChanged += OnTransfersPropertyChanged;

        _ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
    }

    /// <summary>
    /// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
    /// </summary>
    /// <remarks>
    /// A method rather than four more lines in the constructor, because the restart delegate needs a
    /// paragraph of its own and the constructor is already at the length the analyzers allow.
    /// </remarks>
    private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates)
    {
        // The channel is captured rather than reached through the view model, which keeps the restart
        // delegate free of a reference to the object it is being handed to.
        var channel = updates ?? new UnavailableUpdateChannel();

        return new UpdateViewModel(
            channel,
            settings,
            clock,
            () => workspace.LiveSessionCount,

            // Everything this application does on the way out, and only then the swap. Applying an update
            // ends the process, and disposing this view model is what zeroes the identity keys, the vault
            // keys and the cache key — so the other order would leave them sitting in a memory image the
            // installer is about to write over, and would abandon a transfer still writing to a part file.
            //
            // ◆ Unless applying does not end the process, which is the phone. There the swap is a request
            // to the platform's own installer and the answer may be no, so tearing everything down first
            // would answer "not now" with a locked keychain and every shell closed — a punishment for
            // declining an update. Nothing is zeroed in that case, and nothing needs to be while the
            // process is still the one holding it; when the install is agreed to, Android ends the process
            // outright. See IUpdateChannel.ApplyingEndsTheProcess.
            restart: async update =>
            {
                if (channel.ApplyingEndsTheProcess)
                {
                    await DisposeAsync().ConfigureAwait(true);
                }

                channel.ApplyAndRestart(update);
            });
    }

    /// <remarks>
    /// <para>
    /// The page starts at its own default and has no way to know what was stored, so somebody has to tell
    /// it — and it cannot be told before its socket exists. Waiting on the renderer is the only ordering
    /// available; nothing else knows when the page is there.
    /// </para>
    /// <para>
    /// Failure is silence on purpose. A launch where no terminal is ever opened still runs this, and a
    /// renderer that never attached is not a fault in that case — it is the ordinary shape of a session
    /// spent in the keychain. The size is sent again by every change, so nothing is permanently lost.
    /// </para>
    /// </remarks>
    private async Task TellRendererTheFontSizeAsync()
    {
        try
        {
            await workspace.WaitForRendererAsync(CancellationToken.None).ConfigureAwait(false);

            await workspace
                .SetFontSizeAsync(TerminalFontSize, CancellationToken.None)
                .ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
        {
            // No renderer this run. Nothing to tell.
        }
    }

    /// <remarks>
    /// Marshalled, because this arrives on the data plane's receive loop — see
    /// <see cref="TerminalWorkspace.FontSizeStepRequested"/> — and everything it touches is a view model
    /// property somebody's interface is bound to.
    /// </remarks>
    private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
        Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));

    /// <remarks>
    /// <para>
    /// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
    /// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
    /// <see cref="SelectedTab"/> — are bound to by the interface.
    /// </para>
    /// <para>
    /// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
    /// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
    /// they live here, so this is the other half of putting a reattached page back the way it was. The size
    /// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
    /// has changed — the page has merely forgotten, and this is only a reminder.
    /// </para>
    /// </remarks>
    private void OnRendererReattached(object? sender, EventArgs e) =>
        Dispatcher.UIThread.Post(() =>
        {
            _ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();

            if (SelectedTab is { } tab)
            {
                _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
            }
        });

    [ObservableProperty]
    private ShellState state = ShellState.Starting;

    [ObservableProperty]
    private string statusMessage = "Opening the local cache…";

    [ObservableProperty]
    private bool isBusy;

    /// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
    [ObservableProperty]
    private bool canUnlockWithDevice;

    /// <summary>Whether an unlocked vault should offer to register this machine.</summary>
    [ObservableProperty]
    private bool canRegisterDevice;

    /// <summary>Whether this machine has a device key to withdraw.</summary>
    [ObservableProperty]
    private bool canForgetDevice;

    /// <summary>
    /// Whether this machine can neither register a device key nor withdraw one.
    /// </summary>
    /// <remarks>
    /// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
    /// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
    /// register either — and only the second has something to take back. Both false at once is the one case
    /// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
    /// screen where the alternative is a section with no controls in it and no explanation.
    /// </remarks>
    internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;

    /// <summary>
    /// The address offered on first launch, before anything is enrolled.
    /// </summary>
    /// <remarks>
    /// <para>
    /// One default for every build. The hosted deployment is what all but a handful of launches are
    /// aiming at, and typing its address is the only thing standing between an installed application and
    /// a working one. Running against a clone means replacing this with
    /// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
    /// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
    /// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
    /// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
    /// </para>
    /// <para>
    /// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
    /// production by accident. That protection is gone: a debug build now offers the hosted address like
    /// any other, and the first sign-in accepted unread lands there.
    /// </para>
    /// </remarks>
    internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";

    [ObservableProperty]
    private string serverUrl = DefaultServerUrl;

    [ObservableProperty]
    private string passphrase = string.Empty;

    [ObservableProperty]
    private string confirmPassphrase = string.Empty;

    /// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
    [ObservableProperty]
    private string? recoveryCode;

    [ObservableProperty]
    private bool recoveryCodeWrittenDown;

    /// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
    [ObservableProperty]
    private string? accountName;

    /// <summary>
    /// The signed-in account's own email, when the server sent one — for the rail's user popover.
    /// </summary>
    /// <remarks>
    /// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
    /// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
    /// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
    /// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
    /// never invented from the subject or the display name the way a naive fallback would.
    /// </remarks>
    [ObservableProperty]
    private string? email;

    /// <summary>
    /// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
    /// settings page's SIGN-IN row.
    /// </summary>
    /// <remarks>
    /// v5c: <c>MeResponse.Issuer</c> was already being cached into <c>StoredUnlockMaterial.Issuer</c> by
    /// <see cref="AccountProvisioner"/>, for no reader — nothing before this wave surfaced it. Set from the
    /// same two places <see cref="AccountName"/> and <see cref="Email"/> are, in <see cref="AdoptIdentity"/>,
    /// so the three can never drift out of step with which account is actually signed in.
    /// </remarks>
    [ObservableProperty]
    private string? issuer;

    /// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
    /// <remarks>
    /// The first letter of the first two words in <see cref="AccountName"/> — which is already
    /// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
    /// out of its email's local part or its subject rather than a blank circle. Never padded past what the
    /// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
    /// mock's own two-letter shape.
    /// </remarks>
    internal string AvatarInitials
    {
        get
        {
            if (string.IsNullOrWhiteSpace(AccountName))
            {
                return string.Empty;
            }

            var words = AccountName.Split(
                [' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);

            return words switch
            {
                [] => string.Empty,
                [var only] => only[..1].ToUpperInvariant(),
                [var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
            };
        }
    }

    partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));

    /// <summary>
    /// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
    /// </summary>
    /// <remarks>
    /// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
    /// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
    /// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
    /// did not.
    /// </remarks>
    private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null)
    {
        AccountName = displayName ?? emailAddress ?? subject;
        Email = emailAddress;
        Issuer = issuer;
    }

    [ObservableProperty]
    private VaultViewModel? vault;

    /// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
    /// <remarks>
    /// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
    /// vault — unlocking, locking and signing out — cannot get out of step with it.
    /// </remarks>
    [ObservableProperty]
    private KnownHostsViewModel? knownHostsScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private ImportViewModel? importScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private SnippetsViewModel? snippetsScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private LogsViewModel? logsScreen;

    /// <summary>
    /// The vaults screen, which the window binds to whether or not a vault is open.
    /// </summary>
    /// <remarks>
    /// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: both of its
    /// dependencies are fetched through a function at the moment they are needed. That means a lock does
    /// not have to tear it down and an unlock does not have to rebuild it, and the list it is showing
    /// survives both.
    /// <para>
    /// Distinct from <see cref="Vault"/>, which is one vault's <em>contents</em> — the hosts, keys and
    /// passwords the rail's other screens draw. This one is the vaults themselves and the people in them.
    /// </para>
    /// </remarks>
    internal VaultsViewModel Vaults => vaults;

    /// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
    /// <remarks>
    /// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
    /// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
    /// property that went null on lock would be a transfer nothing could report on afterwards.
    /// </remarks>
    internal TransfersViewModel Transfers => transfers;

    /// <summary>Where newer builds come from, which the window binds whether or not a vault is open.</summary>
    /// <remarks>
    /// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell
    /// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left
    /// locked overnight is exactly the one that will have found an update by morning.
    /// </remarks>
    internal UpdateViewModel Updates => updateScreen;

    /// <summary>
    /// Shells that were left running when the vault was locked.
    /// </summary>
    /// <remarks>
    /// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
    /// </remarks>
    [ObservableProperty]
    private int liveSessionCount;

    internal bool HasLiveSessions => LiveSessionCount > 0;

    /// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
    internal string LiveSessionSummary => LiveSessionCount == 1
        ? "1 shell is still connected and still running."
        : $"{LiveSessionCount} shells are still connected and still running.";

    /// <summary>Where the embedded browser should navigate.</summary>
    internal Uri TerminalPageUrl => workspace.PageUrl;

    /// <summary>
    /// Types into a terminal on behalf of something that is not the keyboard.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
    /// composition-root object and a view has no business holding one — the same reason tabs go through
    /// here rather than through <c>TerminalWorkspace.CloseSessionAsync</c>.
    /// </para>
    /// <para>
    /// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
    /// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
    /// way — it goes from the renderer straight down the socket.
    /// </para>
    /// </remarks>
    internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
        workspace.SendInputAsync(sessionId, data, CancellationToken.None);

    /// <summary>
    /// How large the terminal draws, in CSS pixels.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A font size rather than a zoom, and the difference is the whole design. Zoom scales what is already
    /// drawn, so the remote goes on wrapping to a width that is no longer on screen; changing the font size
    /// refits the grid and tells the far end how many columns it now has. That is why this is one number
    /// owned here and pushed to the renderer, rather than a gesture the page handles alone.
    /// </para>
    /// <para>
    /// Owned by the shell rather than by the page for two reasons that pull the same way: it has to survive
    /// a relaunch, and it has to be reachable from a button on a phone that has no keyboard to press
    /// Ctrl+plus with. The page's chords arrive here as steps — see
    /// <see cref="TerminalFontSizeStepEventArgs"/> — so both routes end in this property.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;

    /// <summary>Whether the terminal could be drawn larger than it is.</summary>
    internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;

    /// <summary>Whether the terminal could be drawn smaller than it is.</summary>
    internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;

    /// <summary>Draws the terminal one point larger.</summary>
    [RelayCommand]
    private void EnlargeTerminalFont() => StepTerminalFontSize(1);

    /// <summary>Draws the terminal one point smaller.</summary>
    [RelayCommand]
    private void ShrinkTerminalFont() => StepTerminalFontSize(-1);

    /// <summary>Puts the terminal back to the size it ships at.</summary>
    /// <remarks>
    /// Worth a command of its own rather than leaving people to count clicks back. A terminal that has been
    /// made unreadable is hard to make readable again by eye, which is the state this exists for.
    /// </remarks>
    [RelayCommand]
    private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);

    /// <param name="step">Points to move by, or zero to return to the default.</param>
    private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
        step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);

    /// <remarks>
    /// One path for every route in — the phone's buttons, the page's chords, and the stored value read at
    /// startup — so clamping, persisting and telling the renderer happen once each rather than three times
    /// with one of them eventually forgotten.
    /// </remarks>
    private void ApplyTerminalFontSize(int pixels)
    {
        var clamped = ClientSettings.ClampTerminalFontSize(pixels);

        // Told anyway when nothing moved. A step at the cap is a no-op here, but the page may have been
        // reloaded since the last frame — and a renderer at the wrong size is worse than a redundant frame.
        TerminalFontSize = clamped;

        // Fire and forget: a socket that is not there yet is the ordinary case at startup, and a font size
        // is not worth blocking a button handler on.
        _ = workspace.SetFontSizeAsync(clamped, CancellationToken.None).AsTask();

        // Read-modify-write against the file rather than against a field, so a setting this build does not
        // know about — written by a newer one, or by hand — survives this one storing its own.
        settings.Write(settings.Read() with { TerminalFontSize = clamped });
    }

    partial void OnTerminalFontSizeChanged(int value)
    {
        OnPropertyChanged(nameof(CanEnlargeTerminalFont));
        OnPropertyChanged(nameof(CanShrinkTerminalFont));
    }

    /// <summary>
    /// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
    /// </summary>
    /// <remarks>
    /// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
    /// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
    /// each time. This shell is the window's data context for the life of the process, so one
    /// subscription is enough.
    /// </remarks>
    internal event EventHandler? TerminalSessionOpened;

    /// <summary>
    /// Raised when something this shell did belongs in the terminal the user is already looking at, so the
    /// view can put the keyboard back there.
    /// </summary>
    /// <remarks>
    /// Separate from <see cref="TerminalSessionOpened"/> because no session opened: the sidebar's SNIPS row
    /// typed into one that was already running, and the click that did it moved Win32 focus onto an Avalonia
    /// button. The page cannot fix that from its side — see the <c>term.focus()</c> at the end of
    /// <c>terminal.js</c>'s paste handler, which only ever reaches <c>document.activeElement</c> — so the
    /// half that can only be done by the host is asked for here. The view re-checks that a terminal is
    /// actually showing before it acts; see <c>MainWindow.FocusTerminalWhenLaidOut</c>.
    /// </remarks>
    internal event EventHandler? TerminalFocusRequested;

    internal bool IsStarting => State == ShellState.Starting;

    internal bool IsNeedingServer => State == ShellState.NeedsServer;

    internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;

    internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;

    internal bool IsLocked => State == ShellState.Locked;

    /// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
    /// <remarks>
    /// Its own property because the markup cannot express <c>IsLocked &amp;&amp; !IsConfirmingSignOut</c>,
    /// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
    /// window guarantees at its minimum size, so putting a second question underneath it would push
    /// buttons off a screen with nothing to scroll.
    /// </remarks>
    internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;

    internal bool IsUnlocked => State == ShellState.Unlocked;

    /// <summary>Whether a connection to the server is currently held.</summary>
    internal bool IsOnline => connection is not null;

    /// <summary>
    /// Whether everything this machine has changed has reached the server.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
    /// of that claim, and it is deliberately conservative: true only while a connection is held, the last
    /// pass actually reached the server, and the outbox is empty.
    /// </para>
    /// <para>
    /// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
    /// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
    /// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
    /// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
    /// </para>
    /// <para>
    /// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
    /// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
    /// machine can reach the server and has nothing stuck.
    /// </para>
    /// </remarks>
    internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };

    /// <summary>The same fact as a word, for the titlebar.</summary>
    internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
    {
        (false, _, _) => "OFFLINE",
        (true, true, _) => "UNREACHABLE",
        (true, false, 0) => "SYNCED",
        (true, false, 1) => "1 PENDING",
        (true, false, var pending) => $"{pending} PENDING",
    };

    // ---- Which screen is showing ----

    /// <summary>
    /// Which of the nav rail's screens the page area holds.
    /// </summary>
    /// <remarks>
    /// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
    /// It is what dismissing a terminal returns to.
    /// </remarks>
    [ObservableProperty]
    private ShellScreen screen;

    /// <summary>
    /// Whether the page area is showing rather than a terminal.
    /// </summary>
    /// <remarks>
    /// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
    /// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
    /// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
    /// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
    /// cannot be clicked. See <see cref="IsTerminalShowing"/>.
    /// </remarks>
    internal bool IsShowingPages => Surface is ShellSurface.Page;

    internal bool IsHostsScreen => Screen is ShellScreen.Hosts;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsKeychainScreen => Screen is ShellScreen.Keychain;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsVaultsScreen => Screen is ShellScreen.Vaults;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsLogsScreen => Screen is ShellScreen.Logs;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsMoreScreen => Screen is ShellScreen.More;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsBucketsScreen => Screen is ShellScreen.Buckets;

    /// <summary>
    /// Whether the nav rail should light its Hosts entry.
    /// </summary>
    /// <remarks>
    /// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
    /// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
    /// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
    /// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
    /// at once is one too many.
    /// </remarks>
    internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsKeychainShowing => IsShowingPages && IsKeychainScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsVaultsShowing => IsShowingPages && IsVaultsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsMoreShowing => IsShowingPages && IsMoreScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;

    /// <summary>
    /// Whether the phone's SETTINGS tab should light.
    /// </summary>
    /// <remarks>
    /// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
    /// its destinations would be a bar that only ever lights two of its three entries. This is the one
    /// place where "which tab" and "which screen" are deliberately not the same question — the other two
    /// tabs are each exactly one thing, and this one is seven.
    ///
    /// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
    /// the rail and never asks this. <see cref="ShellScreen.Vaults"/> is in it for the same reason and no
    /// other: the desktop has a rail entry for it and the phone reaches it through the hub, so a
    /// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own
    /// header back over a screen that already has one.
    ///
    /// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
    /// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
    /// session moves between, and the keychain is not one of those — hosts and connections are what
    /// somebody opens the application to do, and keys, credentials and tags are what they go and manage
    /// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
    /// the two heads deliberately arrange differently, after the hub itself.
    /// </remarks>
    internal bool IsMoreSurface =>
        IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
            or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
            or ShellScreen.Vaults or ShellScreen.Keychain;

    /// <summary>
    /// Whether the terminal's WebView may be on screen at this instant.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
    /// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
    /// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
    /// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
    /// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
    /// palette, the phone's connect sheet, and the host-key decision.
    /// </para>
    /// <para>
    /// <b>The host-key decision is the newest of them, and the one that made it a rule rather than a list.</b>
    /// It is drawn over whatever is on screen at the moment a handshake asks the question — including an open
    /// terminal, because a second connection can be being made while a first one is being typed into. Without
    /// this condition the two buttons that answer the most safety-critical question in the product would be
    /// sliced at the WebView's left edge and take no clicks. See <see cref="IsHostKeyDecisionShowing"/>.
    /// </para>
    /// <para>
    /// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
    /// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
    /// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
    /// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
    /// sheet floating over the page underneath. So only the renderer's rectangle is given up.
    /// </para>
    /// <para>
    /// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
    /// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
    /// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
    /// to the same value.
    /// </para>
    /// <para>
    /// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
    /// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
    /// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
    /// Avalonia draws and nothing occludes.
    /// </para>
    /// <para>
    /// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
    /// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
    /// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
    /// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
    /// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
    /// answered here, and it cannot be: this property has no way to know when layout ran.
    /// </para>
    /// <para>
    /// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
    /// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
    /// while this is false; only the bounds are withheld. Removing the control from the tree would not be
    /// safe — that detaches it and destroys the whole WebView2 process tree.
    /// </para>
    /// </remarks>
    internal bool IsTerminalShowing =>
        IsTerminalSurface
        && !IsConnectSheetOpen
        && !IsHostKeyDecisionShowing
        && SelectedTab is { HasSession: true };

    /// <summary>
    /// Whether a host key is waiting to be judged, over whatever the user is looking at.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>It is asked of the shell rather than of a screen because the answer decides an occlusion, and
    /// because the question is no longer tied to a screen.</b> The prompt used to be a banner at the top of
    /// the hosts screen, and the shell navigated there before letting the vault raise it — which is how a
    /// machine typed into the phone's own connect box came to be judged on a list it is deliberately not on.
    /// Both heads now draw the decision over whichever surface was showing when the handshake stopped, so
    /// there is nowhere it can be asked from that it cannot be answered on, and nothing has to move.
    /// </para>
    /// <para>
    /// Two states, one flag, and the views keep them apart: an unknown key is a decision with two buttons and
    /// a changed one is a refusal with no way forward. What they share is that both cover the rectangle the
    /// terminal would be in. See <c>HostKeyCard.axaml</c> on the desktop and <c>HostKeySheet.axaml</c> on the
    /// phone.
    /// </para>
    /// <para>
    /// The transfers screen's own copy of this question is not here, and that is not an omission: file
    /// transfer's prompt is drawn inside that screen, which is a page, so it occludes nothing.
    /// </para>
    /// </remarks>
    internal bool IsHostKeyDecisionShowing =>
        IsUnlocked && Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true };

    /// <summary>
    /// Whether the terminal half of the window is the half being shown, pane or no pane.
    /// </summary>
    /// <remarks>
    /// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
    /// is worth its own name because a tab exists before its session does — see
    /// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
    /// other two say which of the two things that can be in that rectangle is drawn.
    /// </remarks>
    internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;

    /// <summary>
    /// Whether the card that stands in for a pane is showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
    /// tab either has a session or it does not. It covers both of the states in which it does not — still
    /// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
    /// </para>
    /// <para>
    /// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
    /// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
    /// </para>
    /// </remarks>
    internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };

    /// <inheritdoc cref="ShellSurface" />
    [ObservableProperty]
    private ShellSurface surface;

    /// <summary>Points the nav rail at a screen.</summary>
    /// <remarks>
    /// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
    /// something else" and a rail click that changed a screen nobody could see would do nothing visible.
    /// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
    /// </remarks>
    [RelayCommand]
    private void ShowScreen(ShellScreen target)
    {
        // v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either
        // of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant
        // to land in settings mode rather than on the bare screen the design retired. Redirecting here,
        // rather than at every caller, is what makes that true without hunting down every existing call.
        if (target is ShellScreen.Preferences)
        {
            EnterSettings(SettingsPage.Preferences);
            return;
        }

        if (target is ShellScreen.Vaults)
        {
            EnterSettings(SettingsPage.Vaults);
            return;
        }

        // v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on
        // Preferences, and what changes underneath it is the content column and the titlebar's own back
        // label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport.
        if (target is ShellScreen.Import)
        {
            OpenImport();
            return;
        }

        // Any other screen leaves settings mode outright rather than restoring whatever was remembered on
        // the way in — the caller named a destination, and that destination wins over "go back".
        ActiveSettingsPage = null;
        Screen = target;
        Surface = ShellSurface.Page;
    }

    /// <summary>
    /// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
    /// see <c>SettingsView.axaml</c>. Not null exactly while that chrome, rather than the ordinary titlebar
    /// and nav rail, is what <c>MainWindow.axaml</c> draws.
    /// </summary>
    /// <remarks>
    /// A second notion of "where am I" from <see cref="Screen"/> rather than a replacement for it — see the
    /// remark on <see cref="SettingsPage"/>. Two of its five members, <see cref="SettingsPage.Preferences"/>
    /// and <see cref="SettingsPage.Vaults"/>, keep <see cref="Screen"/> in step with the matching
    /// <see cref="ShellScreen"/> member so every binding and test written against that screen before this
    /// mode existed keeps working; the other three have nothing to keep in step with.
    /// </remarks>
    [ObservableProperty]
    private SettingsPage? activeSettingsPage;

    /// <summary>Whether the settings chrome, rather than the ordinary one, is what the window is drawing.</summary>
    internal bool IsSettingsMode => ActiveSettingsPage is not null;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;

    /// <inheritdoc cref="IsSettingsMode" />
    internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;

    /// <summary>
    /// Whether the importer is showing over the Preferences page, inside settings mode.
    /// </summary>
    /// <remarks>
    /// A flag layered on top of <see cref="ActiveSettingsPage"/> rather than a <see cref="SettingsPage"/>
    /// member of its own — Import.dc.html draws <c>SettingsNav</c> lit on Preferences the whole time the
    /// importer is up, which this makes true for free: <see cref="ActiveSettingsPage"/> never leaves
    /// <see cref="SettingsPage.Preferences"/>, so <see cref="IsSettingsPreferencesPage"/> and the nav row it
    /// drives stay exactly as they were. What moves is only the content column, via
    /// <see cref="IsSettingsPreferencesContentShowing"/>, and the titlebar's own back label — see
    /// <c>SettingsTitleBar.axaml</c>.
    /// </remarks>
    [ObservableProperty]
    private bool isImportOpen;

    /// <summary>
    /// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
    /// content column shows.
    /// </summary>
    internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;

    partial void OnActiveSettingsPageChanged(SettingsPage? value)
    {
        OnPropertyChanged(nameof(IsSettingsMode));
        OnPropertyChanged(nameof(IsSettingsGeneralPage));
        OnPropertyChanged(nameof(IsSettingsVaultsPage));
        OnPropertyChanged(nameof(IsSettingsAccountPage));
        OnPropertyChanged(nameof(IsSettingsSecurityPage));
        OnPropertyChanged(nameof(IsSettingsPreferencesPage));
        OnPropertyChanged(nameof(IsSettingsGroupsPage));
        OnPropertyChanged(nameof(IsSettingsTagsPage));
        OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
    }

    partial void OnIsImportOpenChanged(bool value) =>
        OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));

    /// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
    /// <remarks>
    /// The return screen is captured only on the way in from outside settings mode — see
    /// <see cref="settingsReturnScreen"/> — so switching between settings pages, which calls this
    /// repeatedly, cannot overwrite it with another settings page.
    /// <para>
    /// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is
    /// a request for that page, not for whatever was drawn over it the last time settings mode was up.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void EnterSettings(SettingsPage page)
    {
        if (ActiveSettingsPage is null)
        {
            settingsReturnScreen = Screen;
            settingsReturnSurface = Surface;
        }

        ActiveSettingsPage = page;
        IsImportOpen = false;

        Screen = page switch
        {
            SettingsPage.Preferences => ShellScreen.Preferences,
            SettingsPage.Vaults => ShellScreen.Vaults,
            _ => Screen,
        };

        Surface = ShellSurface.Page;
    }

    /// <summary>
    /// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
    /// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.Import"/> for every other caller.
    /// </summary>
    private void OpenImport()
    {
        EnterSettings(SettingsPage.Preferences);
        IsImportOpen = true;
    }

    /// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
    [RelayCommand]
    private void CloseImport() => IsImportOpen = false;

    /// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
    [RelayCommand]
    private void LeaveSettings()
    {
        if (ActiveSettingsPage is null)
        {
            return;
        }

        ActiveSettingsPage = null;
        IsImportOpen = false;
        Screen = settingsReturnScreen;
        Surface = settingsReturnSurface;
    }

    /// <summary>Switches to the terminal surface.</summary>
    /// <remarks>
    /// <para>
    /// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
    /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
    /// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
    /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
    /// button, and a button needs a command.
    /// </para>
    /// <para>
    /// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
    /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
    /// screen carries an empty state anyway, which is worth being able to reach deliberately.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void ShowTerminal()
    {
        Surface = ShellSurface.Terminal;

        // Only with nothing open, because that is the only state in which they are drawn — the surface shows
        // the sessions otherwise. Not awaited, for the reason the logs screen's own load is not: navigating
        // must not block on a read, and the list appears under the box the moment it arrives.
        if (!HasTabs)
        {
            _ = RefreshRecentConnectionsAsync();
        }
    }

    /// <summary>
    /// The machines most recently connected to, for the Connections screen to offer when nothing is open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Deduplicated by address, because this is a list of places rather than of events: connecting to one
    /// box nine times in a morning is nine entries in the log and one thing worth offering here. The log
    /// screen shows every one of them; that is what a log is for and this is not one.
    /// </para>
    /// <para>
    /// Capped, and the cap is not about memory. What makes this list useful is that the machine somebody
    /// wants is visible without scrolling, above a keyboard, under the box they would otherwise be typing
    /// into. Twenty rows would push the box off the screen and be a worse version of the log.
    /// </para>
    /// </remarks>
    internal ObservableCollection<ConnectionLogRowViewModel> RecentConnections { get; } = [];

    internal bool HasRecentConnections => RecentConnections.Count > 0;

    /// <summary>How many machines the Connections screen offers.</summary>
    private const int RecentConnectionLimit = 6;

    /// <summary>Re-reads the connection log and takes the most recent distinct machines from it.</summary>
    /// <remarks>
    /// Failures are swallowed, and that is the same call the logs screen makes for the same reason: this is
    /// a convenience under a box that works without it. A screen whose whole purpose is to let somebody
    /// connect should not lead with a decryption error about a list of things they connected to yesterday.
    /// </remarks>
    private async Task RefreshRecentConnectionsAsync()
    {
        if (LogsScreen is not { } logs)
        {
            return;
        }

        try
        {
            await logs.ReloadConnectionsAsync(CancellationToken.None).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            return;
        }

        RecentConnections.Clear();

        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var row in logs.Connections)
        {
            // The live ones are skipped rather than filtered later: a connection that is open right now has
            // a tab, and a tab means this list is not on screen at all. Leaving them in would only matter in
            // the one state where it cannot be seen, which is a rule that would be wrong the moment that
            // stopped being true.
            if (row.IsLive || !seen.Add(row.Address))
            {
                continue;
            }

            RecentConnections.Add(row);

            if (RecentConnections.Count == RecentConnectionLimit)
            {
                break;
            }
        }

        OnPropertyChanged(nameof(HasRecentConnections));
    }

    /// <summary>Goes back to a machine that has been connected to before.</summary>
    /// <remarks>
    /// <para>
    /// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
    /// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
    /// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
    /// authentication the keychain resolves for it. Connecting from here instead would be a third connect
    /// path that had to answer all of that again.
    /// </para>
    /// <para>
    /// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
    /// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
    /// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
    /// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
    /// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
    /// </para>
    /// <para>
    /// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
    /// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
    /// is deliberately not restored is the password: it was never stored, which is the whole point of the
    /// manual path, and a field that filled itself in would be claiming otherwise.
    /// </para>
    /// <para>
    /// A host deleted since it was connected to falls through to the address, which is the honest answer:
    /// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
    /// user has switched off, and for the same reason rather than by accident: selecting it would point the
    /// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
    /// out — arriving at the hosts screen with nothing selected and no explanation.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void ConnectToRecent(ConnectionLogRowViewModel row)
    {
        if (row is null || Vault is not { } vault)
        {
            return;
        }

        if (row.HostId is { } hostId
            && vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
            && vault.IsVaultShown(known.VaultId))
        {
            vault.AskAboutHostCommand.Execute(known);
            ShowScreen(ShellScreen.Hosts);
            return;
        }

        vault.ManualTarget = row.Address;
        vault.ManualStatus = string.Empty;
    }

    // ---- The rail's own page grouping ----

    /// <summary>
    /// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
    /// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
    /// three tabs, and the rail was drawn only under this one; see the file history for that version of
    /// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
    /// now, and the rail is permanent furniture beside every one of the three — but the partition this
    /// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
    /// reads it under a new name for the switcher, the rail's mode-dependent first row reads
    /// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
    /// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
    /// </para>
    /// <para>
    /// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
    /// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
    /// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
    /// </para>
    /// </remarks>
    internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);

    /// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
    private static bool IsVaultsPage(ShellScreen screen) =>
        screen is not (ShellScreen.Transfers or ShellScreen.Buckets);

    // ---- The rail's segmented switcher and its mode-dependent first entry ----
    //
    // v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
    // segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
    // IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
    // segments and one rail row instead, and the partition is the same one: exactly one of "a page under
    // the rail's own list", "the files screen" and "the buckets screen" is ever true.

    /// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
    /// <remarks>
    /// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
    /// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
    /// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
    /// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
    /// used to treat both as "not SFTP, not S3".
    /// </remarks>
    internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;

    /// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
    /// <remarks>
    /// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
    /// straight off the same three flags the switcher above lights, so the row and the segment can never
    /// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
    /// for the matching glyph and the command the row runs.
    /// </remarks>
    internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";

    /// <summary>
    /// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
    /// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
    /// </remarks>
    internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";

    /// <summary>Runs whichever of the three the row is currently naming.</summary>
    /// <remarks>
    /// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
    /// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
    /// answer to, so asking again here would be a second place those three facts could disagree.
    /// </remarks>
    [RelayCommand]
    private void ShowFirstRailItem()
    {
        if (IsBucketsShowing)
        {
            ShowFiles(RemoteKind.Bucket);
        }
        else if (IsTransfersShowing)
        {
            ShowFiles(RemoteKind.Host);
        }
        else
        {
            ShowTerminal();
        }
    }

    // ---- Which vaults this window is showing ----

    /// <summary>
    /// This machine's preferences about which vaults are drawn, or null while nothing is open.
    /// </summary>
    /// <remarks>
    /// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it — the
    /// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
    /// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
    /// opened, so it cannot outlive the session any more than the keyring can.
    /// </remarks>
    private VaultVisibility? visibility;

    /// <summary>
    /// One switch per readable vault, for the rail's user popover.
    /// </summary>
    /// <remarks>
    /// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
    /// per window and per machine, and what they change is what is drawn — see <see cref="VaultVisibility"/>
    /// for the things they deliberately do not change.
    /// </remarks>
    internal ObservableCollection<VaultToggleViewModel> VaultToggles { get; } = [];

    /// <summary>Whether the menu has anything to offer.</summary>
    /// <remarks>
    /// One vault is the ordinary case — somebody who has never joined a team — and a menu holding a single
    /// switch that cannot be moved is a menu that answers nothing. The New vault entry is still worth
    /// having, so this hides the list rather than the flyout.
    /// </remarks>
    internal bool HasVaultSwitches => VaultToggles.Count > 1;

    /// <summary>Refills the switches from the vaults this session can read.</summary>
    /// <remarks>
    /// The readable ones, not every known one: a vault whose grant awaits re-wrap has nothing that would
    /// decrypt, so a switch for it would do nothing and say so to nobody. Personal first, then by name,
    /// which is the order every other vault list in the application uses.
    /// </remarks>
    private void RebuildVaultToggles()
    {
        VaultToggles.Clear();

        if (Vault is { } open && visibility is { } preferences)
        {
            foreach (var readable in open.Session.ReadableVaults
                .OrderByDescending(row => row.IsPersonal)
                .ThenBy(row => row.Name, StringComparer.CurrentCulture))
            {
                VaultToggles.Add(new VaultToggleViewModel(
                    readable.VaultId,
                    readable.Name,
                    readable.IsPersonal,
                    preferences.IsShown(readable.VaultId)));
            }
        }

        OnPropertyChanged(nameof(HasVaultSwitches));
    }

    /// <summary>Shows or stops showing one vault's items.</summary>
    /// <remarks>
    /// <para>
    /// The personal vault is drawn in the menu, ticked, and cannot be switched off — see
    /// <see cref="VaultToggleViewModel.CanHide"/>. Leaving it out of the list would read as a bug, and
    /// letting it be switched off would empty the snippet, log and bucket screens at once, since all three
    /// are read from the active vault alone.
    /// </para>
    /// <para>
    /// Refuses to switch off the last one that is showing. In practice the rule above already makes that
    /// unreachable; it is here for the session whose personal grant is unreadable, where the alternative is
    /// an application that looks broken and gives no clue which menu broke it.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ToggleVaultAsync(VaultToggleViewModel? row)
    {
        if (row is null || Vault is not { } open || visibility is not { } preferences)
        {
            return;
        }

        if (!row.CanHide)
        {
            StatusMessage =
                "Your personal vault is always shown. Everything filed nowhere else lives in it.";
            return;
        }

        var hiding = row.IsShown;

        if (hiding && VaultToggles.Count(toggle => toggle.IsShown) <= 1)
        {
            StatusMessage = "At least one vault has to be showing.";
            return;
        }

        await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
            .ConfigureAwait(true);

        // The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
        // second means the menu and the screen behind it never disagree, even for a frame.
        await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);

        RebuildVaultToggles();

        StatusMessage = hiding
            ? $"'{row.Name}' is no longer shown. It still syncs, and hosts that authenticate with its keys "
                + "still connect."
            : $"'{row.Name}' is showing again.";
    }

    /// <summary>Redraws everything built from the session's vault list.</summary>
    /// <remarks>
    /// Handed to the teams screen, which is where a vault gets made. The switches come from that list and
    /// so does every host, key and pin on the vault screens, so both are a vault out of date the moment one
    /// is created — and neither is on screen at that point, which is exactly why nothing would have noticed.
    /// </remarks>
    private async Task OnVaultsChangedAsync(CancellationToken cancellationToken)
    {
        if (Vault is { } open)
        {
            await open.RefreshVaultsAsync(cancellationToken).ConfigureAwait(true);
        }

        RebuildVaultToggles();
    }

    /// <summary>
    /// Goes to the vaults screen with the new-vault form open.
    /// </summary>
    /// <remarks>
    /// The screen a vault is made on is the one that shows vaults — where the people, the roles and the key
    /// holders already are, which is the next thing anybody making a shared vault wants. The form asks for a
    /// name and nothing else; see <c>VaultsViewModel.CreateVaultAsync</c> for the membership list that is
    /// made behind it.
    /// </remarks>
    [RelayCommand]
    private void ShowNewVault()
    {
        ShowScreen(ShellScreen.Vaults);
        vaults.NewVaultCommand.Execute(null);
    }

    /// <summary>
    /// Goes to the keychain with the bucket editor open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The same shape as <see cref="ShowNewVault"/> and for the same reason: the thing being made lives on
    /// one screen, and the moment somebody wants it happens on another. Here the two are a whole tab apart —
    /// S3 is where a bucket is used and the keychain is where its keys are kept — which is what made the S3
    /// screen's empty picker read as an application that could not add one at all.
    /// </para>
    /// <para>
    /// Silent when the vault is locked, which is a state the button behind this is not reachable in: the
    /// screen it sits on is inside the unlocked half of the window.
    /// </para>
    /// </remarks>
    private void ShowNewBucket()
    {
        ShowScreen(ShellScreen.Keychain);
        Vault?.NewObjectStoreCommand.Execute(null);
    }

    // ---- The phone's connect menu ----

    /// <summary>
    /// Whether the phone's connect menu is open over the terminal.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Drawn by the Android head alone, and shell state rather than something that view could hold on its
    /// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
    /// <see cref="IsTerminalShowing"/>.
    /// </para>
    /// <para>
    /// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
    /// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
    /// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
    /// doing". The menu is that, and every entry on it is one of the two navigation commands above.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private bool isConnectSheetOpen;

    /// <summary>Raises the connect menu over the terminal.</summary>
    /// <remarks>
    /// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
    /// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
    /// holding a terminal hidden that nothing would put back.
    /// </remarks>
    [RelayCommand]
    private void OpenConnectSheet()
    {
        if (!IsTerminalSurface)
        {
            return;
        }

        IsConnectSheetOpen = true;
    }

    /// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
    /// <remarks>
    /// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
    /// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
    /// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
    /// true of routes nobody wrote it for.
    /// </remarks>
    [RelayCommand]
    private void CloseConnectSheet() => IsConnectSheetOpen = false;

    // ---- Open terminals ----

    /// <summary>
    /// Every terminal that has been opened this run, in the order they were opened.
    /// </summary>
    /// <remarks>
    /// On the shell rather than on the vault, and that follows from the lock policy rather than from
    /// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
    /// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
    /// admit to. This object is the window's data context for the life of the process, and so is this list.
    /// </remarks>
    internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];

    [ObservableProperty]
    private TerminalTabViewModel? selectedTab;

    internal bool HasTabs => Tabs.Count > 0;

    private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));

    /// <summary>
    /// Closes one terminal, ending its shell.
    /// </summary>
    /// <remarks>
    /// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
    /// close button and not a menu item: closing the window somebody's job is running in should take exactly
    /// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
    /// </remarks>
    [RelayCommand]
    private async Task CloseTabAsync(TerminalTabViewModel tab)
    {
        if (tab is null)
        {
            return;
        }

        // Removed first, so the workspace's SessionEnded — announced once the close below has fully drained
        // — finds no tab to mark dead and does nothing here. The alternative ordering leaves a window in
        // which a tab that is on its way out is repainted as disconnected.
        var index = Tabs.IndexOf(tab);
        Tabs.Remove(tab);

        if (ReferenceEquals(SelectedTab, tab))
        {
            // The neighbour, preferring the one on the left, which is where the eye already is.
            SelectedTab = Tabs.Count == 0
                ? null
                : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
        }

        // The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
        // terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
        // leave a visible WebView with no pane in it, which reads as the application having broken.
        if (Tabs.Count == 0)
        {
            Surface = ShellSurface.Page;
        }

        RaiseTabState();

        // Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
        // changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
        // terminal just went would keep a lit dot until something else happened to move the selection.
        RefreshConnectedHosts();

        // What the rectangle holds is decided by the selected tab's state, and the line above may well have
        // moved the selection from a card to a pane or the other way round.
        RaiseTerminalState();

        if (!tab.HasSession)
        {
            // Nothing to end: this tab is a connection that has not happened, or one that never will. The
            // attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
            // user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
            // rather than drops, because a running shell nothing names is worse than a tab that reappears.
            foreach (var attemptId in attempts
                .Where(entry => ReferenceEquals(entry.Value, tab))
                .Select(entry => entry.Key)
                .ToArray())
            {
                attempts.Remove(attemptId);
            }

            return;
        }

        await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
    }

    // ---- Quick connect ----

    /// <summary>Whether the quick-connect palette is open over the window.</summary>
    /// <remarks>
    /// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
    /// this is shell state rather than something a view could hold on its own.
    /// </remarks>
    [ObservableProperty]
    private bool isSearching;

    [ObservableProperty]
    private string searchText = string.Empty;

    /// <summary>
    /// The hosts the palette is offering, best match first.
    /// </summary>
    /// <remarks>
    /// The design's box says "search hosts · run command". Only the first half is here: a command palette
    /// needs commands to run, and this application has no snippet or saved-command item type — see
    /// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
    /// worse than a box that promises only what it does.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];

    [ObservableProperty]
    private HostRowViewModel? selectedSearchResult;

    /// <summary>Whether the palette has anything to offer.</summary>
    /// <remarks>
    /// A property rather than <c>{Binding !SearchResults.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 "No host matches that" is shown permanently — under a list of matches.
    /// </remarks>
    internal bool HasSearchResults => SearchResults.Count > 0;

    /// <summary>Opens the palette, or closes it if it is already open.</summary>
    [RelayCommand]
    private void ToggleSearch()
    {
        if (IsSearching)
        {
            CloseSearch();
            return;
        }

        if (!IsUnlocked)
        {
            return;
        }

        SearchText = string.Empty;
        RefreshSearchResults();
        IsSearching = true;
    }

    /// <summary>Dismisses the palette without connecting.</summary>
    [RelayCommand]
    private void CloseSearch()
    {
        IsSearching = false;
        SearchText = string.Empty;
        SearchResults.Clear();
        SelectedSearchResult = null;

        OnPropertyChanged(nameof(HasSearchResults));
    }

    /// <summary>
    /// Selects the highlighted host and connects to it.
    /// </summary>
    /// <remarks>
    /// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
    /// palette inherits every refusal that path already makes — a dangling key binding, a host with no
    /// username, a host key that has changed. A second connect path would be a second place for those to be
    /// forgotten.
    /// </remarks>
    [RelayCommand]
    private async Task ConnectToSearchResultAsync()
    {
        if (Vault is not { } vault || SelectedSearchResult is not { } row)
        {
            return;
        }

        CloseSearch();

        // ◆ IT USED TO GO TO THE HOSTS PAGE FIRST, and the reason it did no longer exists. An unknown or
        // changed host key was answered by a prompt drawn on that page, and the palette opens from any screen,
        // so connecting from the files screen without the jump would have left the question behind the screen
        // that asked it. Both heads draw the decision over the surface now — see IsHostKeyDecisionShowing —
        // and the other thing this connection can say before it dials, a refusal on Vault.Status, is in the
        // status bar, which is a row of the window rather than part of a screen.
        //
        // So the palette leaves the user where they were, which is the whole point of a palette: Ctrl+K over a
        // transfer that is still running should not cost the transfer's screen.
        vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);

        // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
        // generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
        // own source — so passing this one would read as cancellation plumbing that is not there.
        await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
    }

    /// <remarks>
    /// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
    /// that merely contains it, both come before a match found only in the address, and all three come
    /// before one found only in the vault's name. Typing three characters of a name people use daily should
    /// not put that host third. See <see cref="Rank"/>.
    /// </remarks>
    private void RefreshSearchResults()
    {
        SearchResults.Clear();

        if (Vault is not { } vault)
        {
            SelectedSearchResult = null;
            return;
        }

        var query = SearchText.Trim();

        var matches = query.Length == 0
            ? vault.Hosts.AsEnumerable()
            : vault.Hosts
                .Select(host => (host, rank: Rank(host, query)))
                .Where(candidate => candidate.rank < int.MaxValue)
                .OrderBy(candidate => candidate.rank)
                .ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
                .Select(candidate => candidate.host);

        foreach (var host in matches.Take(8))
        {
            SearchResults.Add(host);
        }

        SelectedSearchResult = SearchResults.FirstOrDefault();

        OnPropertyChanged(nameof(HasSearchResults));
    }

    /// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
    /// <remarks>
    /// <para>
    /// <b>The vault comes last, and only when the row prints it.</b> Its name is the widest of the three
    /// readings — one word can match every host in a team's vault at once — so a host whose own name or
    /// address answers the query has to come first, or typing a machine's name would bury it under everybody
    /// else's.
    /// </para>
    /// <para>
    /// <see cref="HostRowViewModel.HasVaultBadge"/> rather than the name itself, so this matches exactly what
    /// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing
    /// displays would answer a query with a list whose reason for existing is invisible — type "personal"
    /// there and every host comes back for no reason anybody can see.
    /// </para>
    /// </remarks>
    private static int Rank(HostRowViewModel host, string query)
    {
        if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
        {
            return 0;
        }

        if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
        {
            return 1;
        }

        if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
        {
            return 2;
        }

        return host.HasVaultBadge
            && host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
            ? 3
            : int.MaxValue;
    }

    /// <summary>
    /// Brings the schema up to date and works out which screen to show.
    /// </summary>
    /// <remarks>
    /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
    /// That is the point of migrating rather than recreating: a user who upgrades while offline must still
    /// be able to open their vault.
    /// </remarks>
    internal async Task StartAsync(CancellationToken cancellationToken)
    {
        // Before anything that can return early, and outside the try: looking for a newer build does not
        // depend on there being a profile, a server or a vault, and a machine that never gets past the setup
        // screen is still one that should not be running a build with a hole in it. Start() is a no-op on a
        // copy that cannot replace itself.
        updateScreen.Start();

        try
        {
            paths.EnsureCreated();
            await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);

            var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);

            if (profile is null)
            {
                State = ShellState.NeedsServer;
                StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
                return;
            }

            AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer);
            ServerUrl = profile.ServerUrl;
            State = ShellState.Locked;
            StatusMessage = $"Enrolled against {profile.ServerUrl}.";

            // Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
            // back. Offering the button without the second would prompt for a key that is not there; without
            // the first it would prompt for a wrap that is not there. Neither failure is one a user could
            // make sense of, so the button simply does not appear.
            CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
                && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            State = ShellState.NeedsServer;
            StatusMessage = $"The local cache could not be opened: {exception.Message}";
        }
    }

    /// <summary>Discovers the server and runs the browser sign-in.</summary>
    [RelayCommand]
    private async Task SignInAsync(CancellationToken cancellationToken)
    {
        if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
        {
            StatusMessage = "That is not a valid server URL.";
            return;
        }

        // Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
        // URI whose scheme is "localhost" — and then fails much later with something unrelated to the
        // actual mistake.
        if (url.Scheme is not ("http" or "https"))
        {
            StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
            return;
        }

        await RunAsync(
            "Opening your browser to sign in…",
            explain: exception => ExplainSignInFailure(exception, url),
            work: async () =>
            {
                connection?.Dispose();
                connection = null;

                connection = await signIn(url, cancellationToken).ConfigureAwait(true);

                OnPropertyChanged(nameof(IsOnline));
                RaiseSyncState();

                // The browser is finished with, and what follows is a round trip to the DodoSSH server
                // that can take a while or fail on its own. Saying so is the difference between a wait
                // and a hang: a screen still reading "Opening your browser to sign in…" while the server
                // is the thing struggling sends the user back to a browser that did nothing wrong.
                StatusMessage = $"Signed in. Asking {url.Host} about your account…";

                var outcome = await Provisioner()!
                    .RefreshAsync(ServerUrl, cancellationToken)
                    .ConfigureAwait(true);

                AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer);
                StatusMessage = outcome.Message;

                if (outcome.Status == ProvisionStatus.EnrollmentRequired)
                {
                    State = ShellState.NeedsEnrollment;
                    return;
                }

                // An unlocked vault stays unlocked. This command is reachable from the preferences screen
                // of a running application — it is how somebody whose sign-in expired gets back online —
                // and moving the state machine to Locked there would throw an unlock screen over an open
                // vault whose keys are still in memory, which is neither locked nor honest.
                if (IsUnlocked)
                {
                    await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
                    return;
                }

                State = ShellState.Locked;
            }).ConfigureAwait(true);
    }

    /// <summary>Creates the identity key and the personal vault.</summary>
    [RelayCommand]
    private async Task EnrollAsync(CancellationToken cancellationToken)
    {
        if (Provisioner() is not { } provisioner)
        {
            StatusMessage = "Sign in first.";
            return;
        }

        if (!ValidateNewPassphrase())
        {
            return;
        }

        await RunAsync(
            "Creating your keychain. This deliberately takes a moment…",
            async () =>
            {
                var chosen = Passphrase;

                var outcome = await Task
                    .Run(
                        () => provisioner.EnrollAsync(
                            ServerUrl,
                            chosen,
                            deviceName,
                            "Personal",
                            cancellationToken),
                        cancellationToken)
                    .ConfigureAwait(true);

                ConfirmPassphrase = string.Empty;
                RecoveryCode = outcome.RecoveryCode;
                RecoveryCodeWrittenDown = false;
                StatusMessage = outcome.Message;

                // A brand-new account always yields a code. An account someone else already enrolled does
                // not, and there is nothing to show.
                State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Puts the recovery code on the clipboard.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>The one secret in this application that is deliberately offered to the clipboard, and the
    /// contrast with <c>VaultViewModel.CopyPublicKeyAsync</c> is the whole argument.</b> There, copying the
    /// <em>private</em> key is refused outright, because installing a key means pasting the public half and
    /// the private one has no business leaving the vault. Here there is no better route: the code exists for
    /// one screen, is stored nowhere, and has to reach a password manager — so the clipboard is the intended
    /// destination rather than a way around the design.
    /// </para>
    /// <para>
    /// Both screens already made the code selectable, and both said why: a person who cannot get it out of
    /// the box photographs the screen, and a screenshot is a far worse home for it than a clipboard. This is
    /// that argument finished. Selecting a monospaced, letter-spaced string with a thumb is the version of
    /// "possible" that people give up on.
    /// </para>
    /// <para>
    /// It says what it did, including the case where there is nothing to say it to — a machine with no
    /// clipboard has to be told so rather than left with a button that appears to do nothing, which is the
    /// same rule the keychain's copy already follows. And the sentence names what has to happen next,
    /// because a clipboard is not somewhere a recovery code may stay: this screen is the only moment it
    /// exists, and the next thing copied replaces it.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task CopyRecoveryCodeAsync()
    {
        if (RecoveryCode is not { Length: > 0 } code)
        {
            return;
        }

        if (copyToClipboard is null)
        {
            StatusMessage = "This machine has no clipboard. Select the code and copy it by hand.";
            return;
        }

        await copyToClipboard(code).ConfigureAwait(true);

        StatusMessage = "Copied. Paste it into your password manager now — this screen is the only place "
            + "it exists, and the next thing you copy replaces it.";
    }

    /// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
    [RelayCommand]
    private void ConfirmRecoveryCode()
    {
        if (!RecoveryCodeWrittenDown)
        {
            StatusMessage = "Confirm you have written the recovery code down first.";
            return;
        }

        // Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
        // model for the rest of the session would undo that.
        RecoveryCode = null;
        State = ShellState.Locked;
        StatusMessage = "Unlock with the passphrase you just chose.";
    }

    /// <summary>Opens the vault.</summary>
    [RelayCommand]
    private async Task UnlockAsync(CancellationToken cancellationToken)
    {
        if (Passphrase.Length == 0)
        {
            StatusMessage = "Enter your keychain passphrase.";
            return;
        }

        await RunAsync(
            "Unlocking…",
            async () =>
            {
                var entered = Passphrase;

                // Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
                // and would otherwise freeze the window mid-unlock.
                var outcome = await Task
                    .Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
                    .ConfigureAwait(true);

                StatusMessage = outcome.Message;

                if (!outcome.IsUnlocked)
                {
                    return;
                }

                Passphrase = string.Empty;

                await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
            }).ConfigureAwait(true);
    }

    /// <summary>What the status line says while the platform's own consent dialogue is up.</summary>
    /// <remarks>
    /// A runtime check rather than a constructor parameter, unlike the device name beside it, and the
    /// difference between the two is why: a device name is a fact about one handset that only the head can
    /// read, whereas which dialogue appears is a fact about the platform this assembly is running on, and a
    /// value every Android head would pass identically is a parameter that only makes the heads longer.
    /// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
    /// deciding whether the prompt in front of them is the one this application asked for.
    /// </remarks>
    private static string GestureWait =>
        OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";

    /// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
    /// <remarks>
    /// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
    /// does block is a Windows consent dialog which belongs on the UI thread anyway.
    /// </remarks>
    [RelayCommand]
    private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
    {
        await RunAsync(
            GestureWait,
            async () =>
            {
                var outcome = await Opener()
                    .UnlockWithDeviceAsync(deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                StatusMessage = outcome.Message;

                if (!outcome.IsUnlocked)
                {
                    // A declined gesture leaves the passphrase box exactly where it was, which is the whole
                    // fallback: the user types instead. Nothing about the screen changes but the message.
                    return;
                }

                await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Registers this machine so a later launch can unlock with a gesture.
    /// </summary>
    /// <remarks>
    /// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
    /// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
    /// open session can seal the bundle.
    /// </remarks>
    [RelayCommand]
    private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
    {
        if (Vault is not { } vault || connection is null)
        {
            StatusMessage = "Sign in first: registering this machine has to reach the server.";
            return;
        }

        await RunAsync(
            GestureWait,
            async () =>
            {
                var registered = await vault.Session
                    .RegisterDeviceAsync(connection.Account, deviceKeys, deviceName, cancellationToken)
                    .ConfigureAwait(true);

                if (!registered)
                {
                    StatusMessage = "This machine has nowhere to keep a device key.";
                    return;
                }

                CanRegisterDevice = false;
                CanForgetDevice = true;

                // Named rather than "this machine", because the account lists several and this is the
                // sentence that says which one just gained the ability to open the vault.
                StatusMessage = $"'{deviceName}' can now unlock without your passphrase.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Withdraws this machine's device key, here and on the account.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
    /// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
    /// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
    /// </para>
    /// <para>
    /// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
    /// the useful half always happens — the account being told is the half that can be out of reach.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
    {
        if (Vault is not { } vault)
        {
            return;
        }

        await RunAsync(
            GestureWait,
            async () =>
            {
                var revocation = await vault.Session
                    .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                CanForgetDevice = false;

                // Not re-offered here even though it is now true, because registering probes the TPM and
                // this is not the moment to do it: somebody who has just withdrawn a device is not about to
                // add one back, and the offer reappears on the next unlock.
                StatusMessage = revocation switch
                {
                    DeviceRevocation.Complete =>
                        "This machine no longer unlocks without your passphrase, and the account no longer "
                        + "lists it.",
                    DeviceRevocation.LocalOnly =>
                        "This machine no longer unlocks without your passphrase. You are offline, so the "
                        + "account still lists it — sign in and withdraw it again to finish.",
                    _ => "There was no device key on this machine.",
                };
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Takes ownership of a freshly opened session, whichever door opened it.
    /// </summary>
    /// <remarks>
    /// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
    /// a second copy would be a second chance to get it wrong.
    /// </remarks>
    private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
    {
        await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);

        // Before the vault view model, because that is what reads it — and read at all rather than defaulted
        // to "everything shown", because a vault somebody set aside last week should still be set aside.
        visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);

        Vault = new VaultViewModel(
            session,
            workspace,
            knownHosts,
            () => connection,
            ReconnectAsync,
            copyToClipboard,
            connectionLog,
            visibility);
        State = ShellState.Unlocked;

        // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
        // has not already registered one. Asked once here rather than recomputed, because the answer
        // involves a TPM probe.
        CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
            && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);

        // The other side of the same fact, and it needs its own flag rather than the negation of that one:
        // "not offered because this machine has no TPM" and "not offered because it is already registered"
        // are both !CanRegisterDevice, and only the second has anything to withdraw.
        CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;

        await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);

        // After the load, because the switches are built from the vaults the session admitted and the
        // keyring is filled during it — before, and a machine with a team vault would come up with one
        // switch until something else rebuilt them.
        RebuildVaultToggles();

        // After the load, because what the transfers screen takes from the vault is the host list and an
        // empty one would leave its picker blank until the next unlock.
        transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());

        // After the list exists, and it matters after a lock rather than after the first unlock: shells kept
        // running while the vault was closed, so some of these hosts are connected before their rows are a
        // second old.
        RefreshConnectedHosts();

        // After the first load, so the list is on screen before anything talks to a server. The loop is
        // started from the UI thread deliberately: every pass resumes here, which is what keeps the
        // observable collections single-threaded.
        //
        // Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
        // server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
        // this unlock followed a sign-in or a cold launch on a train.
        //
        // Deliberately not awaited here, and not done before this point either. Resuming is a discovery
        // call and a token exchange — a network round trip, and on an unreachable network a slow one —
        // and unlocking must never wait on one. Everything the unlock screen promises about working
        // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
        // the titlebar says OFFLINE until the round trip this starts has an answer.
        Vault.StartAutoSync();
    }

    /// <summary>
    /// Points the two process-lifetime stores at the session that has just opened.
    /// </summary>
    /// <remarks>
    /// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
    /// called by the workspace — so both are attached here rather than constructed per session, and both are
    /// released together on every path that closes a vault.
    /// </remarks>
    private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
    {
        // Before the vault view model, so the first connection after an unlock already knows which host keys
        // this user has approved. Reading them is one listing; doing it here rather than lazily is what
        // keeps it off the SSH handshake thread.
        try
        {
            await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
        }
        catch
        {
            // Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
            // session is vault keys left in memory for the life of the process, which is precisely what
            // unlocking must be able to undo.
            await session.DisposeAsync().ConfigureAwait(true);
            throw;
        }

        // The actor is the account that unlocked, which is what makes this an audit record rather than a
        // list of events with nobody attached to them.
        connectionLog.Open(session, session.Profile.UserId);
    }

    /// <summary>
    /// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
    /// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
    /// and a machine that comes back from a closed lid is online again within a minute of having a
    /// network — with nothing pressed and no browser opened.
    /// </para>
    /// <para>
    /// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
    /// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
    /// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
    /// any more than it can read the hosts.
    /// </para>
    /// <para>
    /// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
    /// token is not a transient condition and will refuse it again once a minute forever, so that one is
    /// said out loud and the token is dropped.
    /// </para>
    /// </remarks>
    private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
    {
        if (connection is { } held)
        {
            await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
            return held;
        }

        if (resume is not { } handler || resuming || Vault is not { } vault)
        {
            return null;
        }

        resuming = true;

        try
        {
            return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
        }
        finally
        {
            resuming = false;
        }
    }

    /// <remarks>
    /// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
    /// things rather than one long one. Everything about why this behaves as it does is up there.
    /// </remarks>
    private async Task<IVaultServer?> ResumeAsync(
        VaultViewModel vault,
        ResumeHandler handler,
        CancellationToken cancellationToken)
    {
        try
        {
            var token = await vault.Session
                .ReadRememberedSignInAsync(cancellationToken)
                .ConfigureAwait(true);

            if (token is null
                || !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
            {
                return null;
            }

            var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);

            connection = resumed;
            rememberedToken = token;

            OnPropertyChanged(nameof(IsOnline));
            RaiseSyncState();

            // The refresh that just happened may have rotated the token, and the rotated one is the only
            // one the next launch can use.
            await RememberSignInAsync(cancellationToken).ConfigureAwait(true);

            return resumed;
        }
        catch (OidcException exception)
        {
            // The provider answered and said no: the session was revoked, or the token was rotated and
            // this machine kept the old one. Retrying costs a round trip a minute and can only ever get
            // the same answer, so the token goes and the user is told the one thing that fixes it.
            await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);

            Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
                + "Sign in again from Preferences to start syncing.");

            return null;
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Everything else is a machine with no network, a server that is down, or a vault that was
            // locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
            // titlebar already says OFFLINE; a socket error once a minute would say nothing more.
            return null;
        }
    }

    /// <summary>
    /// Writes the connection's current refresh token into the vault, if it has changed.
    /// </summary>
    /// <remarks>
    /// Called on every pass rather than driven by an event, because providers rotate the token inside a
    /// refresh that happens on whatever thread an API call was made from — and a value read once a minute
    /// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
    /// the next launch and nothing else, which is not worth interrupting anybody over.
    /// </remarks>
    private async Task RememberSignInAsync(CancellationToken cancellationToken)
    {
        if (connection?.RefreshToken is not { } token
            || Vault is not { } vault
            || string.Equals(token, rememberedToken, StringComparison.Ordinal))
        {
            return;
        }

        try
        {
            await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
            rememberedToken = token;
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Left unremembered. The application is signed in for this run either way.
        }
    }

    /// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
    private async Task ForgetSignInAsync(CancellationToken cancellationToken)
    {
        rememberedToken = null;

        if (Vault is not { } vault)
        {
            return;
        }

        try
        {
            await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // A cache that will not take the deletion is one the next launch will fail to resume from and
            // then delete itself. Nothing here is worth a message.
        }
    }

    /// <summary>
    /// Says something wherever the user is looking.
    /// </summary>
    /// <remarks>
    /// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
    /// a message about the connection, which is the shell's business but only interesting while somebody
    /// is using an open vault, has to go to both or it is invisible half the time.
    /// </remarks>
    private void Announce(string message)
    {
        StatusMessage = message;

        if (Vault is { } vault)
        {
            vault.Status = message;
        }
    }

    /// <summary>
    /// Closes the vault and forgets every key it held. Open shells keep running.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
    /// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
    /// transfer is most likely to be in flight — so killing every shell would make Lock a button that
    /// destroys work, and the predictable response is to stop using it and leave the vault open instead.
    /// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
    /// terminated a running job would be worse than the exposure it removes.
    /// </para>
    /// <para>
    /// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
    /// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
    /// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
    /// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
    /// retroactively un-authorise a session any more than revocation can — the same honest limit the
    /// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
    /// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
    /// inferred from a terminal that the lock screen hides.
    /// </para>
    /// <para>
    /// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
    /// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
    /// warning of this kind.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task LockAsync()
    {
        // First, and before the session it read from goes: a synchronisation pass may be in flight, and it
        // ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
        // reappearing behind a lock screen.
        knownHosts.Close();

        // Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
        // about to be disposed. Tickets already open keep the repository they were opened against, so a
        // shell still running closes out into the vault it was actually made in.
        connectionLog.Close();

        // Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
        // holding references to them. What it does not give up is its connection or its queue — a transfer
        // in flight is exactly the work this method exists not to destroy.
        transfers.Detach();

        if (Vault is { } open)
        {
            Vault = null;
            await open.DisposeAsync().ConfigureAwait(true);
        }

        // With the session, because it was read out of that session's cache. Keeping it would be a set of
        // switches describing vaults nothing can open, offered on a lock screen.
        visibility = null;
        RebuildVaultToggles();

        LiveSessionCount = workspace.LiveSessionCount;

        // A confirmation armed on the preferences screen must not survive onto the unlock screen, where
        // the same card is offered with a warning it can no longer count.
        IsConfirmingSignOut = false;

        State = ShellState.Locked;
        StatusMessage = "Locked.";
    }

    // ---- Signing out ----

    /// <summary>Whether the sign-out confirmation is showing.</summary>
    /// <remarks>
    /// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
    /// action in the application that destroys something a user cannot get back from here — an unpushed
    /// change — and it has to be able to say what is about to go before it goes.
    /// </remarks>
    [ObservableProperty]
    private bool isConfirmingSignOut;

    /// <summary>
    /// What signing out costs, on this machine, right now.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The count is the part worth having. Everything else in the vault is on the server and comes back
    /// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
    /// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
    /// </para>
    /// <para>
    /// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
    /// the honest form of the same warning rather than a zero it has not earned.
    /// </para>
    /// </remarks>
    internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
    {
        (false, _) =>
            "Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
            + "counted from here, because the keychain is locked.",
        (true, 0) =>
            "Everything this machine has changed has reached the server, so nothing will be lost.",
        (true, 1) =>
            "1 change has not reached the server yet and will be lost. Sync first to keep it.",
        (true, var pending) =>
            $"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
    };

    /// <summary>Asks whether the user means it.</summary>
    [RelayCommand]
    private void SignOut()
    {
        // Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
        // does not close a shell any more than locking does, and a screen that sends somebody back to
        // "connect to your server" while their upgrade is still running should say so.
        LiveSessionCount = workspace.LiveSessionCount;

        OnPropertyChanged(nameof(SignOutWarning));

        IsConfirmingSignOut = true;
    }

    /// <summary>Thinks better of it.</summary>
    [RelayCommand]
    private void CancelSignOut() => IsConfirmingSignOut = false;

    /// <summary>
    /// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever
    /// the window is showing.
    /// </summary>
    /// <remarks>
    /// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
    /// inline on the Account settings page while the vault is unlocked — see <c>SettingsAccountPage.axaml</c>
    /// — and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
    /// setup half of the window, which is hidden the whole time this one is reachable. Calling
    /// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
    /// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is
    /// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see
    /// <see cref="ShowNewVault"/>.
    /// <para>
    /// v5c: went to <c>ShellScreen.Preferences</c> before this wave, because that bare screen was the only
    /// place the confirmation card could be seen. It moved to the Account settings page with the card — see
    /// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row
    /// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void SignOutFromPopover()
    {
        EnterSettings(SettingsPage.Account);
        SignOut();
    }

    /// <summary>
    /// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
    /// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
    /// here and on the account. The vault itself is on the server and is untouched, which is what makes
    /// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
    /// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
    /// </para>
    /// <para>
    /// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
    /// while there is still a session and a connection to withdraw it through; the vault is closed before
    /// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
    /// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
    /// </para>
    /// <para>
    /// It does not end the session at the identity provider — there is no back channel to it from here,
    /// and pretending otherwise would be the sort of claim this project writes down instead of implying.
    /// The refresh token this machine held is dropped and never used again; the provider's own session
    /// outlives it, which is what the preferences screen says out loud.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
    {
        await RunAsync(
            "Signing out…",
            async () =>
            {
                IsConfirmingSignOut = false;

                await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);

                // As Lock does, and before the session it reads from goes.
                knownHosts.Close();
                connectionLog.Close();

                // The same detach locking does, and the same reasoning carried one step further: the host
                // rows go because the vault behind them is about to be disposed, and the session and its
                // queue stay because a transfer in flight is somebody's work. Signing out is the strongest
                // thing this application does to itself and it still does not destroy that, for exactly the
                // reason it does not close a shell — quitting DodoSSH is what ends both.
                transfers.Detach();

                if (Vault is { } open)
                {
                    Vault = null;
                    await open.DisposeAsync().ConfigureAwait(true);
                }

                // With the session, as on lock — and here the cache it came from is about to be deleted
                // outright, so the switches would be describing vaults this machine no longer has a row for.
                visibility = null;
                RebuildVaultToggles();

                connection?.Dispose();
                connection = null;
                rememberedToken = null;

                await caches.ResetAsync(cancellationToken).ConfigureAwait(true);

                LiveSessionCount = workspace.LiveSessionCount;

                AccountName = null;
                Email = null;
                Issuer = null;
                Passphrase = string.Empty;
                ConfirmPassphrase = string.Empty;
                RecoveryCode = null;
                RecoveryCodeWrittenDown = false;
                CanUnlockWithDevice = false;
                CanRegisterDevice = false;
                CanForgetDevice = false;

                State = ShellState.NeedsServer;

                OnPropertyChanged(nameof(IsOnline));
                RaiseSyncState();

                StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
                    + "keychain itself is untouched. Sign in to set this machine up again.";
            }).ConfigureAwait(true);
    }

    /// <remarks>
    /// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
    /// half that stops the account listing a machine whose key is about to be deleted — and a server that
    /// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
    /// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
    /// because the profile holding the wrap is emptied a moment later.
    /// </remarks>
    private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (Vault is { } vault)
            {
                await vault.Session
                    .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
                return;
            }

            await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Nothing to report: the wipe below is what signing out actually is.
        }
    }

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

        disposed = true;

        workspace.SessionEnded -= OnWorkspaceSessionEnded;
        workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
        workspace.RendererReattached -= OnRendererReattached;
        transfers.PropertyChanged -= OnTransfersPropertyChanged;

        // Stopped here rather than left to the process exiting with it: the loop holds no vault key and
        // nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
        // object would otherwise leak.
        StopLastConnectedTick();

        // The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
        // than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
        await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
        sessionElapsedTick.Dispose();

        // Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
        // vault because the restart path disposes this whole object and then applies the update — so a
        // check still running would be writing into a view model the process is about to replace.
        await updateScreen.DisposeAsync().ConfigureAwait(false);

        knownHosts.Close();

        // Detached before it is disposed, so a session torn down after this point finds nothing to post to
        // rather than a completed channel. Disposed rather than merely closed, because it owns a background
        // task — and it waits only as long as that task takes to stop, never for the queue to drain.
        workspace.ConnectionLog = null;
        await connectionLog.DisposeAsync().ConfigureAwait(false);

        // Before the vault, and it waits: a transfer still writing has an open remote file and an open local
        // one, and a process that exits while those are in flight leaves a part file longer than the bytes
        // that reached it.
        await transfers.DisposeAsync().ConfigureAwait(false);

        if (Vault is { } open)
        {
            await open.DisposeAsync().ConfigureAwait(false);
        }

        connection?.Dispose();
    }

    /// <remarks>
    /// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
    /// server-side reset exists. A length floor is a crude check and still the one that matters most.
    /// </remarks>
    private bool ValidateNewPassphrase()
    {
        if (Passphrase.Length < 12)
        {
            StatusMessage = "Use a passphrase of at least 12 characters.";
            return false;
        }

        if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
        {
            StatusMessage = "The two passphrases do not match.";
            return false;
        }

        return true;
    }

    /// <remarks>
    /// Sync limits come from the server when there is one, so a batch is never larger than this
    /// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
    /// </remarks>
    private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);

    private AccountProvisioner? Provisioner() =>
        connection is null
            ? null
            : new AccountProvisioner(
                connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);

    /// <remarks>
    /// Every command funnels through here so the busy flag and the failure message are handled once. A
    /// command that forgot either would leave the window permanently disabled or silently doing nothing.
    /// </remarks>
    /// <param name="busyMessage">Shown while the work runs.</param>
    /// <param name="work">The work.</param>
    /// <param name="explain">
    /// Turns a failure into something a user can act on. Optional, because most failures here already
    /// carry their own explanation; the ones that do not are the ones crossing into another process's
    /// vocabulary, where the exception describes a symptom and not the mistake.
    /// </param>
    private async Task RunAsync(
        string busyMessage,
        Func<Task> work,
        Func<Exception, string>? explain = null)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;
        StatusMessage = busyMessage;

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

    /// <remarks>
    /// <para>
    /// Two cases earn a translation rather than the exception's own words, and both are cases where the
    /// exception names a symptom belonging to somebody else's process.
    /// </para>
    /// <para>
    /// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be
    /// established", which sends people looking for a certificate problem. The scheme is the mistake, and
    /// the development stack serves HTTP, so this is the first thing a new user will hit.
    /// </para>
    /// <para>
    /// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already
    /// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The
    /// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having
    /// failed, and the search starts in the wrong place. Naming which server, and saying that its own
    /// logs hold the reason, is the whole content of the fix; the client cannot know more than that.
    /// </para>
    /// </remarks>
    private static string ExplainSignInFailure(Exception exception, Uri server)
    {
        if (exception is DodoSshApiException api && (int)api.StatusCode >= 500)
        {
            return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering "
                + $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than "
                + "anything to fix here — its own logs carry the reason.";
        }

        var secureChannelFailed = exception is HttpRequestException
            && exception.GetBaseException() is AuthenticationException;

        if (secureChannelFailed && server.Scheme is "https")
        {
            var plain = new UriBuilder(server) { Scheme = "http" }.Uri;

            return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
                + $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
        }

        return exception.Message;
    }

    /// <remarks>
    /// One place for the subscription, so unlocking, locking and disposing all route through it rather
    /// than each remembering to detach.
    /// </remarks>
    partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
    {
        if (oldValue is not null)
        {
            oldValue.PropertyChanged -= OnVaultPropertyChanged;
            oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;

            // The four connection events are kept while an attempt is still in flight, and that is not an
            // oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
            // what holds both, and it outlives every vault — so a connection started just before a lock still
            // has an answer coming, and the tab standing in for it is still in the strip afterwards, because
            // tabs are this object's rather than the vault's. Detaching here would strand that tab on
            // "connecting…" for ever and leave the session it eventually opened with nothing in the window
            // naming it, and so no way to close it. The subscription dies with the vault once the attempt
            // resolves: the vault holds the handler, not the other way round.
            oldValue.VaultsChanged -= OnVaultsAdmitted;
            oldValue.FilesRequested -= OnVaultFilesRequested;

            if (attempts.Count == 0)
            {
                oldValue.ConnectionStarting -= OnVaultConnectionStarting;
                oldValue.ConnectionProgress -= OnVaultConnectionProgress;
                oldValue.ConnectionFailed -= OnVaultConnectionFailed;
                oldValue.SessionOpened -= OnVaultSessionOpened;
            }
        }

        if (newValue is not null)
        {
            newValue.ConnectionStarting += OnVaultConnectionStarting;
            newValue.ConnectionProgress += OnVaultConnectionProgress;
            newValue.ConnectionFailed += OnVaultConnectionFailed;
            newValue.SessionOpened += OnVaultSessionOpened;
            newValue.PropertyChanged += OnVaultPropertyChanged;

            // A vault somebody shared arrives on a synchronisation pass rather than through anything the
            // user pressed here — so the menu that lists them is rebuilt from the event rather than at the
            // end of a command, which is the one place a newly admitted vault has no command to be at the
            // end of.
            newValue.VaultsChanged += OnVaultsAdmitted;

            // ◆ The phone's action bar asking for a host's files rather than a shell on it. An event because
            // the screen it leads to is this object's and the transfers view model behind it is a sibling of
            // the vault rather than a part of it; which machine is the vault's business, because a host is a
            // decrypted item. See OnVaultFilesRequested.
            newValue.FilesRequested += OnVaultFilesRequested;

            // The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
            // disconnected — so without this the status dots go out once a minute underneath terminals that
            // are still open. The rows belong to the vault and the connection state belongs to the shell,
            // which is exactly why the shell has to repaint them rather than the vault carrying the flag.
            newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
        }

        // Built from the vault and thrown away with it, here rather than at each of the three places a
        // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
        // would keep a disposed vault alive and repaint a screen nobody can reach.
        KnownHostsScreen?.Detach();

        // v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below
        // — KnownHostsViewModel has no business knowing ShellScreen exists.
        KnownHostsScreen = newValue is null
            ? null
            : new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain));

        // v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page
        // beneath it without ImportViewModel knowing anything about settings mode — the same reasoning
        // VaultViewModel's copyToClipboard delegate is built on.
        ImportScreen = newValue is null
            ? null
            : new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);

        SnippetsScreen?.Detach();
        SnippetsScreen = newValue is null
            ? null
            : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);

        LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);

        // Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
        // and the account and endpoint dialled — and a lock that left them on the shell would be a list of
        // where somebody works, still on screen and still readable, after the thing that decrypted it was
        // disposed and every key it held was zeroed.
        RecentConnections.Clear();
        OnPropertyChanged(nameof(HasRecentConnections));

        RaiseSyncState();

        // The host-key decision is a property of the vault, so swapping the vault out is one of the ways it
        // stops being on screen — locking with a question still up is the case, and it is reachable: a
        // handshake that stopped on an unknown key does not hold the window, so the lock button is live
        // behind the card asking about it.
        RaiseTerminalState();
    }

    /// <remarks>
    /// <para>
    /// Named properties are watched rather than all of them: the titlebar's sync state is the vault's outbox
    /// depth, which lives on the vault, and re-raising the shell's derived properties on every notification a
    /// busy vault produces would repaint the titlebar on every keystroke in an editor.
    /// </para>
    /// <para>
    /// The two host-key flags are here because the decision the vault raises collapses this shell's WebView —
    /// see <see cref="IsHostKeyDecisionShowing"/>. It is a property rather than an event because it is a
    /// state the vault is in and not a moment: it is entered by a refused handshake, left by either answer,
    /// and cleared outright by forgetting a pin from a host's editor, which is a third caller that has
    /// nothing to do with connecting.
    /// </para>
    /// </remarks>
    private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
            || string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
        {
            RaiseSyncState();
        }

        if (string.Equals(
                e.PropertyName, nameof(VaultViewModel.HasPendingHostKey), StringComparison.Ordinal)
            || string.Equals(
                e.PropertyName, nameof(VaultViewModel.HasHostKeyMismatch), StringComparison.Ordinal))
        {
            RaiseTerminalState();
        }
    }

    /// <remarks>
    /// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts
    /// with neither, and this is the one place both know a rebuild just happened. No log read here — see
    /// <see cref="VaultViewModel.RestringLastConnected"/> — only the timestamps already on hand, written
    /// onto whichever row objects exist now.
    /// </remarks>
    private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        RefreshConnectedHosts();
        Vault?.RestringLastConnected();
    }

    private void RaiseSyncState()
    {
        OnPropertyChanged(nameof(IsFullySynced));
        OnPropertyChanged(nameof(SyncLabel));

        // The same fact from a third direction: what signing out would cost is the outbox depth, and a
        // confirmation card left showing a count from before the last pass would be quoting a number that
        // has since been sent.
        OnPropertyChanged(nameof(SignOutWarning));
    }

    /// <summary>
    /// Puts a tab in the strip for a connection that has only just been asked for.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This is what stops connecting looking like the application having stopped. The tab appears in the same
    /// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
    /// machine that is asleep is a card that says which machine, rather than a status line under a window
    /// that does nothing for thirty seconds.
    /// </para>
    /// <para>
    /// Kept by attempt id rather than by label: several connections can be in flight now that one does not
    /// hold the vault, and two of them can perfectly well be to the same host.
    /// </para>
    /// </remarks>
    private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
    {
        var tab = new TerminalTabViewModel(e.Label, e.Address);
        attempts[e.AttemptId] = tab;

        AdoptTab(tab);
    }

    /// <summary>
    /// Moves a connecting tab's step list on, from the handshake's own report.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The one place the phases raised by <c>VaultViewModel.ConnectionProgress</c> are marshalled, and the
    /// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
    /// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
    /// </para>
    /// <para>
    /// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
    /// on this thread — the first is reported before the handshake has yielded at all — and a
    /// <c>CheckAccess</c> fast path for them would buy one dispatcher turn on a card that is up for seconds,
    /// at the price of the two orderings existing at once and only one of them being the one a test runs.
    /// </para>
    /// <para>
    /// A step that arrives after the attempt has settled is harmless and needs no guard here:
    /// <see cref="TerminalTabViewModel.Advance"/> ignores anything reported to a tab that is no longer
    /// connecting, which is what a posted phase landing behind its own <see cref="OnVaultSessionOpened"/>
    /// looks like.
    /// </para>
    /// <para>
    /// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
    /// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
    /// and its session is still adopted if it opens.
    /// </para>
    /// </remarks>
    private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
    {
        if (attempts.TryGetValue(e.AttemptId, out var tab))
        {
            tab.Advance(e.Step);
        }
    });

    /// <summary>
    /// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
    /// </summary>
    /// <remarks>
    /// The vault itself is already on every screen by the time this runs — the pass that admitted it
    /// reloaded the lists — so this is only the one thing the vault view model has no way to reach: the
    /// switches in the tab strip, which are built here from the session's vault list.
    /// </remarks>
    private void OnVaultsAdmitted(object? sender, EventArgs e) => RebuildVaultToggles();

    /// <remarks>
    /// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
    /// them and nothing more.
    /// </remarks>
    private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
    {
        if (!attempts.Remove(e.AttemptId, out var tab))
        {
            // No tab was opened for this attempt, which means the user closed the connecting tab while the
            // handshake was still running. The session is real and has to be adopted rather than dropped:
            // dropping it would leave a shell running with nothing in the window naming it.
            var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
            {
                StartedAt = clock.GetUtcNow(),
                Cipher = NullIfEmpty(e.Cipher),
                HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
                IdentityLabel = e.IdentityLabel,
            };
            AdoptTab(adopted);
            RefreshConnectedHosts();
            return;
        }

        tab.Opened(e.SessionId);

        // From this moment, not from when the tab first appeared — connecting is not open, and the session
        // shell's elapsed timer is about a shell that is actually running.
        tab.StartedAt = clock.GetUtcNow();
        tab.Cipher = NullIfEmpty(e.Cipher);
        tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
        tab.IdentityLabel = e.IdentityLabel;

        // The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
        // the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
        RaiseTerminalState();

        // Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
        // no pane to name until this line.
        Activate(tab);

        RefreshConnectedHosts();

        TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>
    /// Answers a connection that did not become a session.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
    /// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
    /// now, and the status line they are not looking at is not where a failure should end. A host key question
    /// is not a refusal: nothing has gone wrong and there is nothing to report, so the tab goes and the
    /// decision takes its place until it is answered.
    /// </para>
    /// <para>
    /// <b>It used to move the window, and that was the wrong half of the problem to solve.</b> The prompt was
    /// a banner on the hosts screen, so this navigated there — <c>Screen = Hosts</c>, <c>Surface = Page</c> —
    /// on the reasoning that a connection can be started from the palette on any screen and a question behind
    /// the screen somebody is looking at is a question nobody can answer. True, and answered the wrong way
    /// round: what it did to the one connection that has no host at all was to judge a machine typed into the
    /// phone's connect box on a list it is deliberately not on, after taking the box away.
    /// </para>
    /// <para>
    /// Both heads now draw the decision over whatever is showing, so nothing has to move and the palette's
    /// case is covered without a special one. What is left here is the tab, and the surface it was on stays as
    /// it is — for the phone that surface <em>is</em> the connect box, and it is what the user comes back to
    /// whichever way they answer.
    /// </para>
    /// </remarks>
    private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
    {
        if (!attempts.Remove(e.AttemptId, out var tab))
        {
            return;
        }

        if (!e.IsAwaitingAnAnswer)
        {
            tab.Failed(e.Reason);
            RaiseTerminalState();
            return;
        }

        var index = Tabs.IndexOf(tab);
        Tabs.Remove(tab);
        RaiseTabState();

        if (ReferenceEquals(SelectedTab, tab))
        {
            // The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
            SelectedTab = Tabs.Count == 0
                ? null
                : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
        }

        // Nothing else. The decision is drawn over the surface the user is on rather than on a screen they
        // have to be taken to — see the remark — and the neighbour selected above is what that surface shows
        // once the question is answered. Its WebView is collapsed while the card is up, which is the one
        // thing a card in that rectangle cannot do for itself; see IsHostKeyDecisionShowing.
        RaiseTerminalState();
    }

    /// <summary>
    /// Takes a tab into the strip and shows it.
    /// </summary>
    /// <remarks>
    /// One method rather than one per way of opening a session, so the order of these steps is decided once.
    /// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
    /// against a strip that already shows what it is about.
    /// </remarks>
    private void AdoptTab(TerminalTabViewModel tab)
    {
        Tabs.Add(tab);
        RaiseTabState();

        // Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
        // that has one. A tab that is still connecting has none, and selecting it shows the card instead.
        SelectedTab = tab;

        // The surface, but deliberately not the screen. A session opened from the files screen shows its
        // terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
        // clicking away comes back to the transfer that is presumably still running.
        Surface = ShellSurface.Terminal;

        if (tab.HasSession)
        {
            TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
        }
    }

    /// <remarks>
    /// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
    /// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
    /// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
    /// </remarks>
    partial void OnSelectedTabChanged(TerminalTabViewModel? value)
    {
        foreach (var tab in Tabs)
        {
            tab.IsSelected = ReferenceEquals(tab, value);
        }

        // Which of the two things can be in the terminal's rectangle depends on the selected tab having a
        // session, so moving the selection is one of the ways that answer changes. It also repaints the
        // strip's active mark, which follows the selection and the surface together.
        RaiseTerminalState();

        RefreshConnectedHosts();

        // The snippets screen names the terminal its buttons will type into, and it has no way to learn that
        // a different tab is selected — the tab list is the shell's, and a subscription the other way would
        // be a screen keeping the shell alive.
        SnippetsScreen?.TargetChanged();

        if (value is not null)
        {
            Activate(value);
        }
    }

    /// <summary>Tells the renderer which pane to show.</summary>
    /// <remarks>
    /// <para>
    /// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
    /// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
    /// one wrong pane until the next click; blocking the setter would cost the tab strip.
    /// </para>
    /// <para>
    /// The workspace's own token is not available here, so this passes none. The send is a single frame on
    /// an already-open socket and returns immediately when there is no renderer.
    /// </para>
    /// <para>
    /// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
    /// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
    /// the handshake finishes.
    /// </para>
    /// </remarks>
    private void Activate(TerminalTabViewModel tab)
    {
        if (!tab.HasSession)
        {
            return;
        }

        _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
    }

    /// <summary>Which terminal a snippet would go into right now.</summary>
    /// <remarks>
    /// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
    /// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
    /// say, the most recently opened would send a command somewhere the user is not looking.
    /// </remarks>
    /// <summary>The connections that are open and therefore have no log entry yet.</summary>
    /// <remarks>
    /// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
    /// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
    /// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
    /// </remarks>
    private IReadOnlyList<LiveConnection> LiveConnections() =>
        [
            .. connectionLog.Open().Select(open => new LiveConnection(
                open.HostLabel, open.Address, open.StartedAt, deviceName)),
        ];

    private InsertTarget CurrentInsertTarget() =>
        SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;

    /// <summary>Brings one terminal's pane to the front, and shows it.</summary>
    /// <remarks>
    /// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
    /// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
    /// would answer only one of those.
    /// </remarks>
    [RelayCommand]
    private void SelectTab(TerminalTabViewModel tab)
    {
        SelectedTab = tab;
        Surface = ShellSurface.Terminal;
    }

    /// <summary>
    /// Marks a tab dead when its shell ends on its own.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
    /// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
    /// the scrollback, and the renderer has already written the reason into it.
    /// </para>
    /// <para>
    /// <b>Also where a session ending re-reads the connection log</b>, per decision 4: the host just closed
    /// is the one whose ago-text is about to stop being suppressed by <see cref="HostRowViewModel.IsConnected"/>,
    /// and it deserves "just now" rather than whatever it last said. <see cref="ConnectionRecorder.Closed"/>
    /// runs before this event does, but its own write is queued onto a background task rather than made
    /// inline — see its remarks — so a read landing before that write drains shows the previous entry
    /// instead. It self-heals on the next activation or the next session end, which is judged an acceptable
    /// gap rather than worth blocking this handler on the recorder's queue.
    /// </para>
    /// </remarks>
    private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
        Dispatcher.UIThread.Post(() =>
        {
            if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
            {
                tab.IsLive = false;
            }

            RefreshConnectedHosts();
            _ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
        });

    /// <summary>
    /// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Matched on the label, which is what a tab was named after, because that is the only handle the two
    /// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
    /// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
    /// wrong than a dot that goes dark when the vault is reopened.
    /// </para>
    /// <para>
    /// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
    /// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
    /// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
    /// sites for the list of moments that counts as.
    /// </para>
    /// </remarks>
    private void RefreshConnectedHosts()
    {
        if (Vault is not { } vault)
        {
            ActiveTabPinnedPaths.Clear();
            OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
            RaiseSessionState();
            return;
        }

        HostRowViewModel? connectedActiveHost = null;

        foreach (var host in vault.Hosts)
        {
            host.IsConnected = Tabs.Any(
                tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));

            // The active tab's host, and only when it is actually connected: the sidebar is for a session
            // that is open, not for whichever machine's tab happens to be selected while it is still dialling.
            if (host.IsConnected
                && SelectedTab is { } selected
                && string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
            {
                connectedActiveHost = host;
            }
        }

        ActiveTabPinnedPaths.Clear();

        if (connectedActiveHost is not null)
        {
            foreach (var path in connectedActiveHost.Host.PinnedPaths)
            {
                ActiveTabPinnedPaths.Add(path);
            }
        }

        OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
        RaiseSessionState();
    }

    /// <summary>
    /// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
    /// </summary>
    /// <remarks>
    /// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
    /// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
    /// under this name and this shape across v5b, which moved its one reader from a chip strip above the
    /// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
    /// only where they are drawn.
    /// </remarks>
    internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];

    /// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
    internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;

    /// <summary>
    /// Whether the v5b session sidebar draws at all.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface &amp;&amp; HasActiveTabPinnedPaths</c>
    /// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
    /// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
    /// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
    /// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
    /// own "hides when no session is active".
    /// </para>
    /// <para>
    /// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
    /// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
    /// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
    /// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
    /// come from the tab list rather than from whichever host SFTP is connected to. Reusing
    /// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
    /// </para>
    /// </remarks>
    internal bool ShowsQuickAccessSidebar =>
        (IsTerminalSurface && SelectedTab is not null)
        || (IsTransfersShowing && Transfers.IsConnected);

    /// <summary>
    /// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back.
    /// </summary>
    /// <remarks>
    /// A separate question from <see cref="ShowsQuickAccessSidebar"/>, and the two are not interchangeable:
    /// that one is "is there a session for this to be about", which the shell answers, and this one is "does
    /// the person want to see it", which only they can. Closed still draws something — a 34-pixel rail with
    /// the way back on it; see <c>SessionSidebar.axaml</c> — because a panel that vanishes with no trace of
    /// how to get it back is one people report as lost rather than as closed. Remembered between launches;
    /// see <see cref="ToggleSessionSidebar"/> and <c>ClientSettings.SessionSidebarOpen</c>.
    /// </remarks>
    [ObservableProperty]
    private bool isSessionSidebarOpen = true;

    /// <summary>Opens the session sidebar, or closes it to its rail.</summary>
    /// <remarks>
    /// Written through on every toggle rather than on shutdown: this shell is disposed on paths that do not
    /// all run to completion — a killed process, a phone's activity going away — and a preference that
    /// survives only a clean exit is one that will sometimes be forgotten for no reason the user can see.
    /// The store swallows its own failures and says whether it wrote; nothing here can do anything useful
    /// with the answer, so the toggle stands whether or not the disk took it.
    /// </remarks>
    [RelayCommand]
    private void ToggleSessionSidebar()
    {
        IsSessionSidebarOpen = !IsSessionSidebarOpen;

        _ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen });
    }

    /// <summary>
    /// The label on the sidebar's cross-surface row: where the other half of this host is.
    /// </summary>
    /// <remarks>
    /// v5c-4 moved this button off the session shell's own 60-pixel header row and into the sidebar, and the
    /// header went with it — see <c>SessionSidebar.axaml</c>. What the two surfaces hand in separately used
    /// to be a pair of properties on the header control; it is resolved here now, for the same reason
    /// <see cref="SessionAddress"/> is: the sidebar is one control drawn on both surfaces, and a view that
    /// branched on which one it was would be asking a question the shell has already answered.
    /// </remarks>
    internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal";

    /// <summary>Goes to the other half of the session the sidebar is about.</summary>
    /// <remarks>
    /// The two directions were two commands bound from two usages of the header control, and they still are
    /// two methods — <see cref="SelectFilesHostAsync"/> takes a tab and opens an SFTP connection to its host;
    /// <see cref="OpenTerminalForFilesHostAsync"/> dials a fresh terminal at whatever SFTP has open, because
    /// there is no terminal session to reuse. What is new is only that one control now asks for both, so the
    /// branch lives here beside <see cref="SessionCrossSurfaceLabel"/>, which has to agree with it.
    /// </remarks>
    [RelayCommand]
    private async Task OpenOtherSurfaceAsync()
    {
        if (IsTerminalSurface)
        {
            if (SelectedTab is { } tab)
            {
                await SelectFilesHostAsync(tab).ConfigureAwait(true);
            }

            return;
        }

        await OpenTerminalForFilesHostAsync().ConfigureAwait(true);
    }

    /// <summary>
    /// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
    /// </summary>
    /// <remarks>
    /// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
    /// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
    /// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
    /// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
    /// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
    /// </remarks>
    [RelayCommand]
    private async Task OpenPinnedPathAsync(string path)
    {
        if (Vault is not { } vault
            || SelectedTab is not { } tab
            || vault.Hosts.FirstOrDefault(
                host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
        {
            return;
        }

        await GoToHostFilesAsync(host, path).ConfigureAwait(true);
    }

    // ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----

    /// <summary>
    /// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
    /// </summary>
    /// <remarks>
    /// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
    /// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
    /// underneath it agrees what it is editing.
    /// </remarks>
    [RelayCommand]
    private void AddSnippetFromSidebar()
    {
        ShowScreen(ShellScreen.Snippets);
        SnippetsScreen?.NewCommand.Execute(null);
    }

    /// <summary>
    /// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
    /// writing to the renderer directly — that command already carries the whole safety story the snippets
    /// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
    /// that runs. A second insert path here would be a second place that story could go stale.
    /// </para>
    /// <para>
    /// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
    /// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
    /// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
    /// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
    /// that one gap, the same as clicking a row with nothing selected would otherwise be.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task InsertSnippetAsync(SnippetRowViewModel snip)
    {
        if (SnippetsScreen is not { } screen || snip is null)
        {
            return;
        }

        screen.Selected = snip;

        if (screen.CanInsert)
        {
            await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);

            // The click that got here took the keyboard off the terminal and gave it to the sidebar row, so
            // the command lands at a prompt that cannot be typed at until somebody clicks the pane. Asked
            // for after the insert rather than before it, so the caret arrives to find the text already
            // there. See TerminalFocusRequested.
            TerminalFocusRequested?.Invoke(this, EventArgs.Empty);
            return;
        }

        ShowScreen(ShellScreen.Snippets);
    }

    /// <summary>
    /// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
    /// </summary>
    /// <remarks>
    /// The closest honest affordance rather than a new one: this application has no way to open the host
    /// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
    /// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
    /// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
    /// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
    /// </remarks>
    [RelayCommand]
    private void PinFolderFromSidebar()
    {
        if (Vault is not { } vault
            || SelectedTab is not { } tab
            || vault.Hosts.FirstOrDefault(
                host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
        {
            return;
        }

        ShowScreen(ShellScreen.Hosts);
        vault.SelectedHost = host;
        vault.EditSelectedHostCommand.Execute(null);
    }

    /// <summary>
    /// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
    /// has no such architecture, and building one is out of this wave's scope. What it has instead is
    /// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
    /// own action already use — so a click on the SFTP tab row honestly does the one thing this application
    /// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
    /// it and land the remote pane there.
    /// </para>
    /// <para>
    /// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
    /// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
    /// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
    /// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
    /// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
    {
        if (Vault is not { } vault
            || tab is null
            || vault.Hosts.FirstOrDefault(
                host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
        {
            return;
        }

        SelectedTab = tab;

        await GoToHostFilesAsync(host, null).ConfigureAwait(true);
    }

    /// <summary>
    /// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
    /// </summary>
    /// <remarks>
    /// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
    /// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
    /// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
    /// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
    /// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
    /// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
    /// </remarks>
    [RelayCommand]
    private async Task OpenTerminalForFilesHostAsync()
    {
        if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
        {
            return;
        }

        vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);

        await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
    }

    /// <summary>
    /// The account and endpoint the session shell's sidebar and status bar are about right now, or null when
    /// neither surface has one.
    /// </summary>
    /// <remarks>
    /// One property reading whichever surface is showing, rather than one binding per surface reading its own
    /// source directly — <c>SessionSidebar.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
    /// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
    /// to. It was the retired header row that printed this first; v5c-4 moved the line into the sidebar's own
    /// session block and left this property exactly as it was. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
    /// which is already the account and endpoint actually dialled — nothing here re-derives it.
    /// </remarks>
    internal string? SessionAddress => Surface switch
    {
        ShellSurface.Terminal => SelectedTab?.Address,
        _ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
        _ => null,
    };

    /// <summary>
    /// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
    /// surface has a session — or when the one it has has nothing to report.
    /// </summary>
    /// <remarks>
    /// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
    /// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
    /// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
    /// </remarks>
    internal string? SessionCipher => Surface switch
    {
        ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
        _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
        _ => null,
    };

    /// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
    /// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
    internal string? SessionHostKeyAlgorithm => Surface switch
    {
        ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
        _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
        _ => null,
    };

    /// <summary>
    /// The display name of the key or credential that authenticated, or null when a typed password did or
    /// nothing is connected.
    /// </summary>
    /// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
    internal string? SessionIdentityLabel => Surface switch
    {
        ShellSurface.Terminal => SelectedTab?.IdentityLabel,
        _ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
        _ => null,
    };

    /// <summary>
    /// The status bar's one run of text for the host key and the identity that authenticated — the design's
    /// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
    /// </summary>
    /// <remarks>
    /// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
    /// one string instead of assembling a separator between two bindings that can each be absent on their own.
    /// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
    /// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
    /// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
    /// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
    /// that was never real.
    /// </remarks>
    internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
        ? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
        : null;

    /// <summary>Null for an empty string, unchanged otherwise.</summary>
    /// <remarks>
    /// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
    /// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
    /// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
    /// rather than null, and the status bar's collapse bindings only know how to ask about null.
    /// </remarks>
    private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;

    /// <summary>Whether the session the header and status bar are describing is actually open.</summary>
    /// <remarks>
    /// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
    /// that is still connecting has an address — it is what the connecting card names — but no live shell
    /// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
    /// </remarks>
    internal bool IsSessionConnected => Surface switch
    {
        ShellSurface.Terminal => SelectedTab?.IsLive is true,
        _ when IsTransfersShowing => Transfers.IsConnected,
        _ => false,
    };

    /// <summary>
    /// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
    /// </summary>
    /// <remarks>
    /// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
    /// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
    /// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
    /// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
    /// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
    /// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
    /// </remarks>
    internal string? SessionElapsedText
    {
        get
        {
            var startedAt = Surface switch
            {
                ShellSurface.Terminal => SelectedTab?.StartedAt,
                _ when IsTransfersShowing => Transfers.ConnectedStartedAt,
                _ => null,
            };

            if (startedAt is not { } started)
            {
                return null;
            }

            var elapsed = clock.GetUtcNow() - started;

            if (elapsed < TimeSpan.Zero)
            {
                // The clock this ran on and the clock the session opened on can disagree by a hair when both
                // are TimeProvider.System, which is close enough to "now" that a negative span is rounding
                // rather than a session that has not started yet.
                elapsed = TimeSpan.Zero;
            }

            return string.Create(
                CultureInfo.InvariantCulture,
                $"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
        }
    }

    /// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
    /// <remarks>
    /// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
    /// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
    /// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
    /// </remarks>
    private void RaiseSessionState()
    {
        OnPropertyChanged(nameof(SessionAddress));
        OnPropertyChanged(nameof(IsSessionConnected));
        OnPropertyChanged(nameof(SessionElapsedText));
        OnPropertyChanged(nameof(SessionCipher));
        OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
        OnPropertyChanged(nameof(SessionIdentityLabel));
        OnPropertyChanged(nameof(SessionIdentityText));
        OnPropertyChanged(nameof(ShowsQuickAccessSidebar));

        // v5c-4: the sidebar's cross-surface row says where the other half of this session is, so it turns
        // over with the surface exactly as the facts above do.
        OnPropertyChanged(nameof(SessionCrossSurfaceLabel));
    }

    /// <remarks>
    /// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
    /// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
    /// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
    /// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
    /// fact the header, the status bar or the SFTP tab row's active mark reads.
    /// </remarks>
    private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
            or nameof(TransfersViewModel.IsConnected)
            or nameof(TransfersViewModel.Remote)
            or nameof(TransfersViewModel.ConnectedTo)
            or nameof(TransfersViewModel.ConnectedCipher)
            or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
            or nameof(TransfersViewModel.ConnectedIdentityLabel))
        {
            RaiseSessionState();
        }
    }

    /// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
    /// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
    private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
    {
        try
        {
            using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);

            while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
            {
                OnPropertyChanged(nameof(SessionElapsedText));
            }
        }
        catch (OperationCanceledException)
        {
            // The shell is closing; see DisposeAsync.
        }
    }

    partial void OnLiveSessionCountChanged(int value)
    {
        OnPropertyChanged(nameof(HasLiveSessions));
        OnPropertyChanged(nameof(LiveSessionSummary));
    }

    partial void OnStateChanged(ShellState value)
    {
        OnPropertyChanged(nameof(IsStarting));
        OnPropertyChanged(nameof(IsNeedingServer));
        OnPropertyChanged(nameof(IsNeedingEnrollment));
        OnPropertyChanged(nameof(IsShowingRecoveryCode));
        OnPropertyChanged(nameof(IsLocked));
        OnPropertyChanged(nameof(IsAskingForThePassphrase));
        OnPropertyChanged(nameof(IsUnlocked));
        RaiseTerminalState();
        OnPropertyChanged(nameof(SignOutWarning));
        RaiseSyncState();

        // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
        // The hosts screen is what this application is for. The surface as well as the screen: shells outlive
        // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
        // the application would not be what "unlocked" looks like.
        if (value is ShellState.Unlocked)
        {
            Screen = ShellScreen.Hosts;
            Surface = ShellSurface.Page;
        }
    }

    /// <remarks>
    /// Every screen flag, on every change, for the same reason the vault column raises all four of its
    /// section flags: a rail lighting the current screen and a body showing it are one fact read from two
    /// directions, and raising only the one that became true leaves the old button lit.
    /// </remarks>
    partial void OnScreenChanged(ShellScreen value)
    {
        RaiseSurfaceState();

        // Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
        // thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
        // appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
        if (value is ShellScreen.Logs && LogsScreen is { } logs)
        {
            _ = logs.RefreshCommand.ExecuteAsync(null);
        }

        // Who is in each vault is read from the server rather than from the vault itself, so there is
        // nothing to show until somebody asks for it — and asking for it on every unlock would be a request
        // per launch for a screen most people never open. Fire-and-forget because a property change cannot
        // await, and because the view model turns every failure into its own status line rather than
        // throwing.
        if (value is ShellScreen.Vaults)
        {
            _ = vaults.LoadAsync(CancellationToken.None);
        }

        UpdateLastConnectedVisibility();
    }

    /// <summary>
    /// Goes to the file screen with one of the two kinds of remote offered.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The phone splits SFTP and S3 into two destinations over this one view model; the desktop has a
    /// single FILES screen with the toggle on it. So the kind is set here, by the thing that navigates,
    /// rather than in <see cref="OnScreenChanged"/> — which would have made every arrival at
    /// <see cref="ShellScreen.Transfers"/> force the picker back to hosts, including the desktop's own nav
    /// rail arriving at a screen with a bucket already open.
    /// </para>
    /// <para>
    /// <b>It will not change the kind while something is open.</b> There is one session behind both
    /// destinations, so switching the picker under a live one would leave a screen titled S3 listing an
    /// SFTP host's files. Refusing and saying so is the honest half of sharing a view model between two
    /// destinations; the screen keeps showing what is actually open.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void ShowFiles(RemoteKind kind)
    {
        // Refusing means staying put, not arriving somewhere and saying no. Moving Screen anyway would put
        // the S3 entry in the sidebar over a screen still listing an SFTP host — two pieces of chrome
        // disagreeing about where you are, which is worse than the navigation simply not happening.
        if (Transfers.IsConnected && Transfers.Remote != kind)
        {
            Transfers.Status = kind is RemoteKind.Bucket
                ? "An SFTP session is open. Close it before opening a bucket."
                : "A bucket is open. Close it before connecting to a host.";

            // Still show the screen the open session belongs to, so the message is somewhere it can be
            // read — the button that was pressed is in the sidebar, which is on screen either way.
            Screen = Transfers.Remote is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
            Surface = ShellSurface.Page;
            return;
        }

        Transfers.Remote = kind;
        Screen = kind is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
        Surface = ShellSurface.Page;
    }

    /// <summary>
    /// Takes the hosts screen to the files screen, on the host it asked about.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>"Connect via SFTP", from the phone's action bar.</b> It was reachable before only by going to the
    /// files screen and choosing the machine again out of a picker — which meant naming a host twice, the
    /// second time on a screen that had no idea one had already been chosen.
    /// </para>
    /// <para>
    /// It goes through <see cref="ShowFiles"/> rather than setting the screen itself, so the one refusal
    /// there — a bucket already open — is made once and made here too. The host is chosen after that call,
    /// because arriving is what clears the picker.
    /// </para>
    /// <para>
    /// <b>It navigates and then connects, and the two are separate on purpose.</b> A host wanting a typed
    /// password cannot be dialled from a list, so that case opens the picker with the machine already chosen
    /// and the box beside it — the same branch the tap on the hosts screen makes, and for the same reason.
    /// Everything else connects, and its failures land on the files screen's own status line, which is where
    /// somebody who has just arrived there is looking.
    /// </para>
    /// <para>
    /// The row is re-found in the transfers screen's own list rather than used directly. That list is a copy
    /// rebuilt from the vault's — see <c>TransfersViewModel.Hosts</c> — and its picker binds to rows in it,
    /// so handing it the vault's object would select nothing.
    /// </para>
    /// </remarks>
    private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
        _ = GoToHostFilesAsync(e.Host, null);

    /// <summary>
    /// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The shared body behind <see cref="OnVaultFilesRequested"/> and <see cref="OpenPinnedPathAsync"/>: which
    /// machine and where to land once it is open are the only two things that differ between "Browse files"
    /// and a click on the pin strip, and both are parameters here.
    /// </para>
    /// <para>
    /// <b>A host asking for a typed password still only opens the picker.</b> <paramref name="path"/> is not
    /// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files
    /// already had before there was a path to carry, and closing it would mean holding a pending navigation
    /// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin
    /// that has to be clicked again once the session is open.
    /// </para>
    /// <para>
    /// <b>It reconnects rather than checking whether this host is already open.</b> The existing "Browse
    /// files" flow — <see cref="TransfersViewModel.ConnectCommand"/> — never asked, and reusing it here
    /// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files
    /// does not have. See <c>hosts-v5-design-spec.md</c>: SFTP is a second authenticated connection, every
    /// time it is opened, by design.
    /// </para>
    /// </remarks>
    private async Task GoToHostFilesAsync(HostRowViewModel host, string? path)
    {
        ShowFiles(RemoteKind.Host);

        if (Screen is not ShellScreen.Transfers)
        {
            // The refusal above stood: a bucket is open, and its message is on screen. Choosing a host under
            // it would leave the picker pointing at a machine nothing is going to dial.
            return;
        }

        Transfers.SelectedHost =
            Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);

        if (Transfers.SelectedHost is null)
        {
            return;
        }

        if (Transfers.SelectedHostAsksForAPassword)
        {
            Transfers.BeginChoosingRemoteCommand.Execute(null);
            Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT.";
            return;
        }

        await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);

        if (path is not null && Transfers.IsConnected)
        {
            await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true);
        }
    }

    /// <inheritdoc cref="OnScreenChanged" />
    /// <remarks>
    /// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
    /// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
    /// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
    /// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
    /// terminal would draw the sheet again over a rectangle held blank by it.
    /// </remarks>
    partial void OnSurfaceChanged(ShellSurface value)
    {
        if (value is not ShellSurface.Terminal)
        {
            IsConnectSheetOpen = false;
        }

        RaiseSurfaceState();
        UpdateLastConnectedVisibility();
    }

    /// <summary>
    /// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Called from both <see cref="OnScreenChanged"/> and <see cref="OnSurfaceChanged"/>, because either one
    /// alone can be what makes <see cref="IsHostsShowing"/> flip: <see cref="ShowScreenCommand"/> moves both
    /// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface,
    /// and switching groups on the hosts screen itself moves neither. <see cref="wasHostsScreenShowing"/> is
    /// what turns two call sites into one decision rather than two chances to double the work.
    /// </para>
    /// <para>
    /// The read this starts is the one <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already argues
    /// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the
    /// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait
    /// on a decrypt pass over its log.
    /// </para>
    /// </remarks>
    private void UpdateLastConnectedVisibility()
    {
        var showing = IsHostsShowing;

        if (showing == wasHostsScreenShowing)
        {
            return;
        }

        wasHostsScreenShowing = showing;

        if (showing)
        {
            _ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
            StartLastConnectedTick();
        }
        else
        {
            StopLastConnectedTick();
        }
    }

    /// <summary>
    /// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
    /// </summary>
    /// <remarks>
    /// <b>No log read here or in <see cref="RunLastConnectedTickAsync"/>.</b> The tick only turns the
    /// timestamps <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already read into new words, through
    /// <see cref="VaultViewModel.RestringLastConnected"/> — reading the connection log itself on a timer is
    /// exactly what <c>LogsViewModel.cs</c> argues that log must never be put on.
    /// </remarks>
    private void StartLastConnectedTick()
    {
        if (lastConnectedTick is not null)
        {
            return;
        }

        var cts = new CancellationTokenSource();
        lastConnectedTick = cts;
        _ = RunLastConnectedTickAsync(cts.Token);
    }

    /// <summary>Stops the loop <see cref="StartLastConnectedTick"/> began, if one is running.</summary>
    /// <remarks>
    /// Cancelled rather than merely forgotten, so the loop's own <c>PeriodicTimer</c> wait unblocks and the
    /// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
    /// </remarks>
    private void StopLastConnectedTick()
    {
        if (lastConnectedTick is not { } cts)
        {
            return;
        }

        lastConnectedTick = null;
        cts.Cancel();
        cts.Dispose();
    }

    private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken)
    {
        try
        {
            using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);

            while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
            {
                Vault?.RestringLastConnected();
            }
        }
        catch (OperationCanceledException)
        {
            // The hosts screen navigated away, or the shell is closing.
        }
    }

    /// <remarks>
    /// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
    /// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
    /// narrow what became stale.
    /// </remarks>
    private void RaiseSurfaceState()
    {
        // ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
        // IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
        // OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
        // off a terminal tab to a page screen, even though the property itself would answer correctly the
        // next time anything else asked it.
        RaiseSessionState();

        OnPropertyChanged(nameof(IsHostsScreen));
        OnPropertyChanged(nameof(IsTransfersScreen));
        OnPropertyChanged(nameof(IsKeychainScreen));
        OnPropertyChanged(nameof(IsVaultsScreen));
        OnPropertyChanged(nameof(IsPreferencesScreen));
        OnPropertyChanged(nameof(IsKnownHostsScreen));
        OnPropertyChanged(nameof(IsSnippetsScreen));
        OnPropertyChanged(nameof(IsLogsScreen));
        OnPropertyChanged(nameof(IsMoreScreen));
        OnPropertyChanged(nameof(IsBucketsScreen));

        OnPropertyChanged(nameof(IsShowingPages));
        OnPropertyChanged(nameof(IsVaultsTab));
        OnPropertyChanged(nameof(IsHostsShowing));
        OnPropertyChanged(nameof(IsTransfersShowing));
        OnPropertyChanged(nameof(IsKeychainShowing));
        OnPropertyChanged(nameof(IsVaultsShowing));
        OnPropertyChanged(nameof(IsPreferencesShowing));
        OnPropertyChanged(nameof(IsKnownHostsShowing));
        OnPropertyChanged(nameof(IsSnippetsShowing));
        OnPropertyChanged(nameof(IsLogsShowing));
        OnPropertyChanged(nameof(IsMoreShowing));
        OnPropertyChanged(nameof(IsBucketsShowing));
        OnPropertyChanged(nameof(IsMoreSurface));

        // The rail's switcher and its mode-dependent first row read the same three flags above, so
        // whatever moved them has to repaint these too — see the remarks on each.
        OnPropertyChanged(nameof(IsSshShowing));
        OnPropertyChanged(nameof(FirstRailItemLabel));
        OnPropertyChanged(nameof(FirstRailItemIcon));

        RaiseTerminalState();
    }

    /// <summary>
    /// Re-reads what the terminal's rectangle should hold, and which tab is lit.
    /// </summary>
    /// <remarks>
    /// One method for all four, because they are one fact read from four directions: the surface, the
    /// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
    /// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
    /// pointing at something nobody can see.
    /// </remarks>
    private void RaiseTerminalState()
    {
        OnPropertyChanged(nameof(IsTerminalSurface));
        OnPropertyChanged(nameof(IsTerminalShowing));
        OnPropertyChanged(nameof(IsConnectingShowing));
        OnPropertyChanged(nameof(IsHostKeyDecisionShowing));

        // ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
        // that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
        // page reached by clicking away from a terminal tab.
        RaiseSessionState();

        // The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
        // navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
        // on screen; see TerminalTabViewModel.IsShowing.
        foreach (var tab in Tabs)
        {
            tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
        }
    }

    partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();

    /// <inheritdoc cref="OnIsSearchingChanged" />
    partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();

    /// <remarks>
    /// The unlock card and the confirmation swap, so arming one has to hide the other — see
    /// <see cref="IsAskingForThePassphrase"/>.
    /// </remarks>
    partial void OnIsConfirmingSignOutChanged(bool value) =>
        OnPropertyChanged(nameof(IsAskingForThePassphrase));

    partial void OnCanRegisterDeviceChanged(bool value) =>
        OnPropertyChanged(nameof(HasNoDeviceKeyOption));

    partial void OnCanForgetDeviceChanged(bool value) =>
        OnPropertyChanged(nameof(HasNoDeviceKeyOption));

    partial void OnSearchTextChanged(string value) => RefreshSearchResults();
}
