using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
internal enum RemoteKind
{
    /// <summary>A host, over SFTP.</summary>
    Host,

    /// <summary>An S3-compatible bucket.</summary>
    Bucket,
}

/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param>
/// <param name="Path">The absolute path that reaches it.</param>
internal sealed record CrumbViewModel(string Name, string Path);

/// <summary>One remote file or directory, as a row.</summary>
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
{
    internal SftpEntry Entry => entry;

    internal string Name => entry.Name;

    internal string FullPath => entry.FullPath;

    internal bool IsNavigable => entry.IsNavigable;

    internal bool IsFile => entry.Kind is SftpEntryKind.File;

    /// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
    /// <remarks>
    /// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
    /// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
    /// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
    /// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
    /// </remarks>
    internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";

    /// <remarks>
    /// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
    /// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
    /// </remarks>
    internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;

    /// <remarks>
    /// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket
    /// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to
    /// report for one and leaves the timestamp at its default. Formatting that prints
    /// <c>0001-01-01 00:00</c>, which is not a quiet "unknown": it is a date, and a listing that states one
    /// for every folder in a bucket is stating something untrue.
    /// </remarks>
    internal string Modified =>
        entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);

    /// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
    internal string Permissions => entry.Permissions;

    /// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
    internal bool IsExecutable => entry.IsExecutable;

    /// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
    internal bool IsWorldWritable => entry.IsWorldWritable;
}

/// <summary>One local file or directory, as a row.</summary>
/// <remarks>
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
/// </remarks>
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
{
    internal LocalEntry Entry => entry;

    internal string Name => entry.Name;

    internal string FullPath => entry.FullPath;

    internal bool IsNavigable => entry.IsDirectory;

    internal bool IsFile => !entry.IsDirectory;

    /// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
    internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";

    internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);

    internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}

/// <summary>How this screen writes a modification time.</summary>
/// <remarks>
/// <para>
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
/// </para>
/// <para>
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
/// </para>
/// </remarks>
internal static class Timestamps
{
    internal static string Format(DateTimeOffset moment) =>
        moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}

/// <summary>One transfer, as a row in the queue.</summary>
/// <remarks>
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
/// </remarks>
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
    [ObservableProperty]
    private TransferSnapshot transfer = snapshot;

    internal Guid Id => Transfer.Id;

    internal string Name => Transfer.Name;

    /// <summary>Which way, as an arrow the eye can scan a column of.</summary>
    internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";

    /// <summary>The end that is not this machine, which is the one worth showing.</summary>
    internal string Path => Transfer.Direction is TransferDirection.Download
        ? Transfer.RemotePath
        : Transfer.LocalPath;

    /// <summary>
    /// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
    /// </summary>
    /// <remarks>
    /// Built from real paths this snapshot already carries rather than a shortened form invented for the
    /// strip: the source is the file name at whichever end the transfer reads from, and the destination is
    /// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
    /// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
    /// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
    /// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
    /// design's own sample data is — a directory two levels down would read as the same word as its parent —
    /// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
    /// instead.
    /// </remarks>
    internal string Label
    {
        get
        {
            var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
                ? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
                : (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);

            // Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
            // translation to name the file at the end of it.
            var source = System.IO.Path.GetFileName(sourcePath);

            return $"{source} → {destinationDirectory}";
        }
    }

    internal double Percent => Transfer.Fraction * 100;

    /// <summary>
    /// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
    /// </summary>
    /// <remarks>
    /// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
    /// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
    /// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
    /// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
    /// the bar alone cannot state exactly.
    /// </remarks>
    internal string StatusWord => Transfer.State switch
    {
        TransferState.Queued => "queued",
        TransferState.Running => string.Create(
            CultureInfo.InvariantCulture, $"{(int)Percent}%"),
        TransferState.Completed => "done",
        TransferState.Cancelled => "stopped",
        _ => "failed",
    };

    /// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
    internal bool IsInProgress => Transfer.State is TransferState.Running;

    /// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
    internal bool IsDone => Transfer.State is TransferState.Completed;

    /// <summary>
    /// What the row says about where it has got to.
    /// </summary>
    /// <remarks>
    /// The bytes and the rate together while it runs, because either alone leaves the obvious question
    /// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
    /// whether it is still moving.
    /// </remarks>
    internal string Progress => Transfer.State switch
    {
        TransferState.Queued => "queued",
        TransferState.Running =>
            $"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
            + $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
        TransferState.Completed => ByteSize.Format(Transfer.Length),
        TransferState.Cancelled when Transfer.Transferred > 0 =>
            $"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
        TransferState.Cancelled => "stopped",
        _ => Transfer.Failure ?? "failed",
    };

    internal string StateLabel => Transfer.State switch
    {
        TransferState.Queued => "QUEUED",
        TransferState.Running => "RUNNING",
        TransferState.Completed => "DONE",
        TransferState.Cancelled => "STOPPED",
        _ => "FAILED",
    };

    internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;

    internal bool IsFinished => Transfer.IsFinished;

    internal bool CanResume => Transfer.CanResume;

    internal bool HasFailed => Transfer.State is TransferState.Failed;

    /// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
    /// <remarks>
    /// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
    /// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
    /// wording differs. See <see cref="RetryLabel"/>.
    /// </remarks>
    internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;

    internal string RetryLabel => CanResume ? "RESUME" : "RETRY";

    /// <remarks>
    /// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
    /// raising only the ones that happened to change is how a progress bar moves under a label that still
    /// says "queued".
    /// </remarks>
    partial void OnTransferChanged(TransferSnapshot value)
    {
        OnPropertyChanged(nameof(Progress));
        OnPropertyChanged(nameof(Percent));
        OnPropertyChanged(nameof(StateLabel));
        OnPropertyChanged(nameof(StatusWord));
        OnPropertyChanged(nameof(IsInProgress));
        OnPropertyChanged(nameof(IsDone));
        OnPropertyChanged(nameof(IsRunning));
        OnPropertyChanged(nameof(IsFinished));
        OnPropertyChanged(nameof(CanResume));
        OnPropertyChanged(nameof(CanRetry));
        OnPropertyChanged(nameof(HasFailed));
        OnPropertyChanged(nameof(RetryLabel));
    }
}

/// <summary>
/// Something on the host that has been asked about and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
/// has no wastebasket to put them in.
/// </para>
/// <para>
/// It carries the full path rather than only the name, because the name is the half that does not identify
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
/// </para>
/// </remarks>
/// <param name="Name">What the row was called.</param>
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
{
    /// <summary>The question, naming the kind because the two behave differently.</summary>
    internal string Question => IsDirectory
        ? $"Delete the directory '{Name}' on the host?"
        : $"Delete '{Name}' on the host?";

    /// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
    internal string Consequence => IsDirectory
        ? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
            + "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
            + "copy and there is no undo."
        : "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
            + "touched, and there is no undo.";
}

/// <summary>
/// The transfers screen: a host, two directory panes, and the queue between them.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
/// The consequence a user sees is that the host records a second login, and that a host whose password is
/// typed each time asks for it again here.
/// </para>
/// <para>
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
/// host list — those are decrypted vault items — and not the connection or the queue.
/// </para>
/// </remarks>
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
{
    private readonly ISftpSessionFactory sftp;
    private readonly FileTransferQueue queue;
    private readonly Action<Action> post;

    /// <summary>
    /// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
    /// <see cref="QueueDeliveredDownload"/>.
    /// </summary>
    /// <remarks>
    /// Compared case-insensitively because the paths come back through the queue's snapshots rather than
    /// straight from the caller, and a comparison that a casing round trip could break would leak a file
    /// per transfer on any head that ever normalises one.
    /// </remarks>
    private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);

    /// <summary>
    /// What to do with a completed download whose real destination this layer cannot write to.
    /// </summary>
    /// <remarks>
    /// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
    /// download that failed once and succeeded on the second attempt must still end up where the person
    /// pointed. See <see cref="QueueDeliveredDownload"/>.
    /// </remarks>
    private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];

    private VaultViewModel? vault;
    private VaultKnownHostStore? knownHosts;
    private IRemoteFileStore? session;
    private ConnectionRecorder? connectionLog;

    /// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
    private IObjectStoreFactory? objectStores;

    /// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
    /// <remarks>
    /// Held rather than rebuilt at close time, because by then the session is being disposed and the host
    /// row it came from may have been replaced by a background sync. The address is the one that was
    /// actually dialled, which is the whole point of capturing it at connect.
    /// </remarks>
    private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;

    /// <summary>
    /// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
    /// connected.
    /// </summary>
    /// <remarks>
    /// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
    /// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
    /// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
    /// the first, for no reader that could not already reach it here.
    /// </remarks>
    internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;

    private bool disposed;

    /// <param name="sftp">Opens SFTP sessions.</param>
    /// <param name="clock">Time source, for transfer rates and timestamps.</param>
    /// <param name="post">
    /// Runs an action on the thread this view model's collections 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 the reason
    /// <c>VaultViewModel</c>'s clipboard is one. <c>Dispatcher.UIThread</c> is process-wide and belongs to
    /// whichever thread touched it first, so a test that posts through it is asserting on a queue owned by
    /// some other test's thread — which passes or throws "the calling thread cannot access this object"
    /// depending on the order a runner happened to schedule its classes in. Running the action inline
    /// removes the thread from the question rather than making the test guess it right.
    /// </para>
    /// </param>
    /// <param name="addBucket">
    /// Takes the user to where a bucket is made, or null in a head that has nowhere to take them.
    /// <para>
    /// A delegate rather than this screen making one itself, and it is the same seam the vaults screen uses
    /// for the shell: a bucket is a keychain item, the editor that writes one belongs to the keychain screen,
    /// and duplicating it here would be a second form writing the same secret. What this screen owns is the
    /// knowledge that somebody standing on it with no buckets needs to be sent somewhere.
    /// </para>
    /// </param>
    internal TransfersViewModel(
        ISftpSessionFactory sftp,
        TimeProvider clock,
        Action<Action>? post = null,
        Action? addBucket = null)
    {
        this.sftp = sftp;
        this.post = post ?? (action => Dispatcher.UIThread.Post(action));
        this.addBucket = addBucket;

        // The supplier answers with whatever session is current at the moment a transfer starts, which is
        // what lets a queue survive a disconnect and reconnect without every queued row failing.
        queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
        queue.Changed += OnTransferChanged;

        // The three "is there anything in it" flags follow their collections rather than being raised by
        // hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
        // which is the life of the process.
        RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
        LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
        Transfers.CollectionChanged += (_, _) =>
        {
            OnPropertyChanged(nameof(HasTransfers));
            OnPropertyChanged(nameof(ActiveTransfersLabel));
        };

        // The fourth, and it follows a list this screen does not own: the buckets are refilled from the
        // vault on every synchronisation pass, so the invitation these flags decide between has to change
        // with them rather than at the moment somebody pressed something. That is the path that matters —
        // adding the first bucket happens on another screen, and this one has to notice when it comes back.
        Buckets.CollectionChanged += (_, _) => RaiseBucketState();
    }

    /// <summary>Where a bucket is made, or null in a head with nowhere to send anybody.</summary>
    private readonly Action? addBucket;

    /// <summary>Goes to the keychain with the bucket editor open.</summary>
    /// <remarks>
    /// The dead end this replaces was a real one: SELECT BUCKET opened a picker with nothing in it, and
    /// nothing anywhere on the screen said that a bucket is made on the keychain screen. Somebody who had
    /// come to S3 to add one had arrived at the place it is used and been shown no route to the place it is
    /// created.
    /// </remarks>
    [RelayCommand]
    private void AddBucket()
    {
        // Closed first, so returning here from the keychain does not find a picker still open over a list
        // that has since gained the bucket it was empty of.
        IsChoosingRemote = false;

        addBucket?.Invoke();
    }

    /// <summary>The hosts that can be connected to, which is the vault's list.</summary>
    /// <remarks>
    /// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
    /// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
    /// be a second decrypted copy of a secret for no gain.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];

    [ObservableProperty]
    private HostRowViewModel? selectedHost;

    /// <summary>The buckets that can be browsed, which is the vault's list.</summary>
    /// <inheritdoc cref="Hosts" path="/remarks" />
    internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];

    [ObservableProperty]
    private ObjectStoreRowViewModel? selectedBucket;

    /// <summary>
    /// Whether there is a bucket to open at all.
    /// </summary>
    /// <remarks>
    /// The question this screen used to answer by showing an empty combo box. A bucket is made on the
    /// keychain screen and nowhere else, and S3 is where somebody goes looking for it — so a picker with
    /// nothing in it and no sentence beside it was, from where the user was standing, an application with no
    /// way to add a bucket. See <see cref="AddBucket"/>.
    /// </remarks>
    internal bool HasBuckets => Buckets.Count > 0;

    /// <summary>Whether this screen is on S3 with a bucket to offer.</summary>
    /// <remarks>
    /// The two flags are resolved here rather than combined in markup, because a binding cannot say "and" —
    /// and because the pair is one question with two answers, which is easier to keep straight in one place
    /// than in four <c>IsVisible</c> expressions that have to stay each other's opposites.
    /// </remarks>
    internal bool ShowsBucketChoice => ShowsBucketPicker && HasBuckets;

    /// <summary>Whether this screen is on S3 with nothing to open yet.</summary>
    internal bool ShowsNoBuckets => ShowsBucketPicker && !HasBuckets;

    /// <summary>
    /// Which sort of remote the right-hand pane is about to open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
    /// the host editor's authentication picker does — and the reason is that these two are not
    /// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
    /// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
    /// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
    /// to look at than two clearly separate choices.
    /// </para>
    /// <para>
    /// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
    /// keychain's categories do: a selection binding moves before a command could refuse it.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private RemoteKind remote;

    /// <summary>Whether the picker is showing hosts.</summary>
    internal bool ShowsHostPicker => Remote is RemoteKind.Host;

    /// <summary>Whether the picker is showing buckets.</summary>
    internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;

    /// <summary>
    /// Whether the picker is open, as opposed to the invitation that offers it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The desktop's connect bar is gone — a strip of controls across the top of a screen that is not
    /// connected to anything, asking a question the empty right-hand pane was already asking silently. What
    /// replaced it is the pane itself: an invitation where the listing would be, and this flag is the step
    /// between "connect to a host" and the picker that does it.
    /// </para>
    /// <para>
    /// Two steps rather than a picker sitting open, because the pane is the whole answer to "why is this
    /// half of the screen empty" and a combo box does not say that. The phone does not use this: its screen
    /// is one pane at a time, so the picker <em>is</em> what it shows before a connection exists.
    /// </para>
    /// <para>
    /// It is cleared by everything that changes what the picker would be picking — connecting, disconnecting,
    /// switching between SFTP and S3, and losing the vault — so an open form is never left over a pane that
    /// has moved on. See the property-changed hooks at the foot of this file.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private bool isChoosingRemote;

    /// <summary>
    /// What the button that opens the remote says.
    /// </summary>
    /// <remarks>
    /// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
    /// connected and nothing stays open. A word that implied otherwise would make the absence of a
    /// DISCONNECT step look like a bug rather than the shape of the protocol.
    /// </remarks>
    internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";

    /// <remarks>
    /// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
    /// separate authentication, so a password typed to open a terminal has not been offered here — and a
    /// screen that quietly reused it would make a one-time password appear to work twice.
    /// </remarks>
    [ObservableProperty]
    private string typedPassword = string.Empty;

    /// <remarks>
    /// It opens saying what the state is rather than what to do about it, and that is a v3 change the
    /// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
    /// it was the only thing on the screen saying so — the connect bar it sat in had a picker and a button
    /// and no prose at all. The invitation that replaced the bar says it in a heading, a sentence and a
    /// button, so a status line repeating it underneath was the same instruction three times.
    /// </remarks>
    [ObservableProperty]
    private string status = "Nothing is open yet.";

    [ObservableProperty]
    private bool isBusy;

    [ObservableProperty]
    private bool isConnected;

    /// <summary>
    /// Whether something is being dragged over the local pane, and whether it would be accepted.
    /// </summary>
    /// <remarks>
    /// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
    /// three-way binding — and because the refusing state is worth showing rather than merely not showing
    /// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
    /// window that has stopped responding.
    /// </remarks>
    [ObservableProperty]
    private bool isLocalDropTarget;

    /// <inheritdoc cref="IsLocalDropTarget" />
    [ObservableProperty]
    private bool isRemoteDropTarget;

    /// <inheritdoc cref="IsLocalDropTarget" />
    [ObservableProperty]
    private bool isRemoteDropRefused;

    /// <summary>The account and endpoint actually dialled, once connected.</summary>
    [ObservableProperty]
    private string? connectedTo;

    /// <summary>
    /// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
    /// while nothing is connected, or while what is connected is a bucket rather than a host.
    /// </summary>
    /// <remarks>
    /// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
    /// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
    /// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
    /// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
    /// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
    /// </remarks>
    [ObservableProperty]
    private string? connectedCipher;

    /// <summary>
    /// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
    /// service question, not the desktop's.
    /// </summary>
    /// <remarks>
    /// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
    /// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
    /// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
    /// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
    /// desktop has no such question because nothing stops its process for having gone quiet.
    /// </remarks>
    internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;

    /// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
    [ObservableProperty]
    private string? connectedHostKeyAlgorithm;

    /// <summary>
    /// The display name of the key or credential that authenticated, or null when a typed password did, or
    /// null while nothing is connected.
    /// </summary>
    /// <remarks>
    /// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
    /// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
    /// to climb, and a second lookup would be a second place for a stale binding to answer differently than
    /// the one that actually authenticated.
    /// </remarks>
    [ObservableProperty]
    private string? connectedIdentityLabel;

    /// <summary>
    /// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws
    /// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See
    /// <see cref="VaultViewModel.EditorPinnedPaths"/> for where a pin is actually added or removed; this is
    /// a read of what was already saved there.
    /// </summary>
    /// <remarks>
    /// Captured at connect, the same moment <see cref="ConnectedTo"/> is, rather than followed live off the
    /// host row's own <c>PinnedPaths</c>. A pin edited while this session stays open shows up on the next
    /// connect rather than mid-session — the same lag <see cref="ConnectedTo"/> itself already carries for
    /// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying
    /// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all:
    /// <see cref="OpenBucketAsync"/> leaves this empty rather than reading as "not yet known", which is what
    /// empty already means for a host that connected with none pinned.
    /// </remarks>
    internal ObservableCollection<string> ConnectedPinnedPaths { get; } = [];

    /// <summary>Whether the connected host or bucket has any pins to draw as chips.</summary>
    /// <remarks>
    /// A read of <see cref="ConnectedPinnedPaths"/> rather than an <c>[ObservableProperty]</c> of its own,
    /// so it is raised by hand at each of the three places that collection is repopulated or cleared —
    /// <see cref="MarkHostConnected"/>, <see cref="OpenBucketAsync"/> and <see cref="CloseSessionAsync"/>.
    /// </remarks>
    internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0;

    [ObservableProperty]
    private HostKeyPresentation? pendingHostKey;

    [ObservableProperty]
    private string? hostKeyMismatch;

    internal bool HasPendingHostKey => PendingHostKey is not null;

    internal bool HasHostKeyMismatch => HostKeyMismatch is not null;

    /// <summary>Whether the chosen host will want something typed into the password box.</summary>
    /// <remarks>
    /// The resolved binding, for the reason its counterpart on <c>VaultViewModel</c> gives: a host naming
    /// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen
    /// has its own password box and so needs its own copy of the question — but it must give the same
    /// answer, or one screen would ask for a password the other knew was not wanted.
    /// </remarks>
    internal bool SelectedHostAsksForAPassword =>
        ShowsHostPicker
        && (SelectedHost is null
            || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);

    // ---- The remote pane ----

    [ObservableProperty]
    private string remotePath = string.Empty;

    internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];

    internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];

    [ObservableProperty]
    private RemoteEntryRowViewModel? selectedRemoteEntry;

    /// <summary>What a new directory would be called, when the user is making one.</summary>
    [ObservableProperty]
    private string newRemoteFolder = string.Empty;

    internal bool HasRemoteEntries => RemoteEntries.Count > 0;

    /// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
    [ObservableProperty]
    private RemoteDeletionRequest? pendingRemoteDeletion;

    internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;

    /// <summary>Whether the pane's DELETE is live.</summary>
    /// <remarks>
    /// Off while its own question is up, so a second press cannot arm a second one behind the card — and
    /// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
    /// move UP and REFRESH out from under the pointer.
    /// </remarks>
    internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;

    // ---- The local pane ----

    [ObservableProperty]
    private string localPath = LocalDirectory.Home;

    internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];

    internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];

    /// <summary>
    /// The drives this machine has, as somewhere the local pane can jump to.
    /// </summary>
    /// <remarks>
    /// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
    /// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
    /// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
    /// be unreachable from an application whose whole purpose on this screen is to move one.
    /// </remarks>
    internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];

    [ObservableProperty]
    private LocalEntryRowViewModel? selectedLocalEntry;

    internal bool HasLocalEntries => LocalEntries.Count > 0;

    // ---- The queue ----

    internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];

    /// <summary>
    /// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
    /// disconnects.
    /// </summary>
    /// <remarks>
    /// For a head that has to tell the operating system what this process is doing — Android's foreground
    /// service, which must be up for as long as bytes are moving, or a host session sits open, and down
    /// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
    /// collection announces rows arriving and leaving, and the transition that matters most is neither of
    /// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
    /// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
    /// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
    /// </remarks>
    internal event EventHandler? ActivityChanged;

    /// <summary>How many transfers are moving or waiting to move.</summary>
    /// <remarks>
    /// Queued counts as active. A queue with three files in it and one of them running is a process that
    /// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
    /// </remarks>
    internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);

    /// <summary>
    /// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
    /// </summary>
    /// <remarks>
    /// A string of its own rather than a converter in the markup, for the reason every other label in this
    /// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
    /// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
    /// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
    /// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
    /// that shape rather than a chip claiming activity that has already finished.
    /// </remarks>
    internal string ActiveTransfersLabel => string.Create(
        CultureInfo.InvariantCulture, $"{ActiveTransfers} active");

    internal bool HasTransfers => Transfers.Count > 0;

    /// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
    internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };

    /// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
    internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };

    /// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
    /// <param name="openVault">The open keychain.</param>
    /// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
    /// <param name="log">
    /// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
    /// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
    /// when the keychain locks still ends somewhere.
    /// </param>
    internal void Attach(
        VaultViewModel openVault,
        VaultKnownHostStore hostKeys,
        ConnectionRecorder? log = null,
        IObjectStoreFactory? buckets = null)
    {
        vault = openVault;
        knownHosts = hostKeys;
        connectionLog = log;
        objectStores = buckets;

        // Followed rather than copied once, which is the difference between a picker that is right at unlock
        // and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
        // synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
        // snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
        // Detach: these collections belong to a vault that is about to be disposed.
        openVault.Hosts.CollectionChanged += OnVaultListChanged;
        openVault.ObjectStores.CollectionChanged += OnVaultListChanged;

        RefreshHosts();

        // Read once per unlock rather than per navigation: a drive appearing while the application is open
        // is possible and rare, and probing every removable drive on every click into a folder is not.
        LocalRoots.Clear();

        foreach (var root in LocalDirectory.Roots())
        {
            LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
        }

        RefreshLocalCommand.Execute(null);
    }

    /// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
    /// <remarks>
    /// <para>
    /// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
    /// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
    /// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
    /// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
    /// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
    /// buttons past the window's edge at the session shell's 472-pixel budget.
    /// </para>
    /// <para>
    /// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
    /// application has ever typed into already means "my home directory" by it, which is exactly what the
    /// chip does when pressed.
    /// </para>
    /// </remarks>
    internal static string RootChipName(string root)
    {
        if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
        {
            return "~";
        }

        var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
        if (trimmed.Length == 0)
        {
            // Unix's "/": trimming eats the whole string, and the root's name is the root itself.
            return "/";
        }

        // A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
        // no file-name segment at all, and the trimmed root is already the two-character name it always had.
        var name = Path.GetFileName(trimmed);
        return name.Length == 0 ? trimmed : name;
    }

    /// <summary>
    /// Gives up the vault, keeping the connection and anything in flight.
    /// </summary>
    /// <remarks>
    /// The host list goes because those rows carry decrypted secrets and the vault they came from is being
    /// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
    /// </remarks>
    internal void Detach()
    {
        if (vault is { } open)
        {
            open.Hosts.CollectionChanged -= OnVaultListChanged;
            open.ObjectStores.CollectionChanged -= OnVaultListChanged;
        }

        vault = null;
        knownHosts = null;

        Hosts.Clear();
        Buckets.Clear();
        SelectedHost = null;
        SelectedBucket = null;
        TypedPassword = string.Empty;

        // The picker with it. Its two lists have just been emptied, so leaving it open would show a form
        // offering a choice between nothing.
        IsChoosingRemote = false;
    }

    // ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this
    // screen offers is a destination now rather than a control on the screen — both heads reach it through
    // MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first.
    // Keeping the command would have left one nothing could invoke.

    /// <summary>Opens the picker, from the invitation in the empty remote pane.</summary>
    [RelayCommand]
    private void BeginChoosingRemote() => IsChoosingRemote = true;

    /// <summary>
    /// Puts the picker away without connecting.
    /// </summary>
    /// <remarks>
    /// The typed password goes with it. It is a secret nobody asked to keep, and leaving it in the box would
    /// mean the next person to open the picker — for a different host, possibly — starts with somebody
    /// else's password already typed in.
    /// </remarks>
    [RelayCommand]
    private void CancelChoosingRemote()
    {
        IsChoosingRemote = false;
        TypedPassword = string.Empty;
    }

    /// <summary>Opens the chosen remote, whichever kind it is.</summary>
    [RelayCommand]
    private Task ConnectAsync(CancellationToken cancellationToken) =>
        Remote is RemoteKind.Bucket
            ? OpenBucketAsync(cancellationToken)
            : ConnectToHostAsync(cancellationToken);

    /// <summary>
    /// Opens the chosen bucket.
    /// </summary>
    /// <remarks>
    /// <para>
    /// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
    /// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
    /// why the failure this reports is a listing failure rather than a connection one — there is no
    /// connection to fail.
    /// </para>
    /// <para>
    /// It goes through the same session field, the same queue and the same panes as a host, because by this
    /// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
    /// SFTP and needed no change.
    /// </para>
    /// </remarks>
    private async Task OpenBucketAsync(CancellationToken cancellationToken)
    {
        if (objectStores is not { } factory)
        {
            Status = "This build cannot open buckets.";
            return;
        }

        if (SelectedBucket is not { } row)
        {
            Status = "Choose a bucket first.";
            return;
        }

        PendingHostKey = null;
        HostKeyMismatch = null;

        await RunAsync(
            $"Opening {row.Label}…",
            async () =>
            {
                await CloseSessionAsync().ConfigureAwait(true);

                session = factory.Open(row.Store);

                IsConnected = true;
                ConnectedTo = string.Create(
                    CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");

                // No SSH underneath a bucket, so none of the three has an honest value — see
                // ConnectedCipher's own remark.
                ConnectedCipher = null;
                ConnectedHostKeyAlgorithm = null;
                ConnectedIdentityLabel = null;

                // And no pins either — see ConnectedPinnedPaths's own remark.
                ConnectedPinnedPaths.Clear();
                OnPropertyChanged(nameof(HasConnectedPins));

                connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());

                await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);

                Status = $"Opened {row.Label}.";
            }).ConfigureAwait(true);
    }

    /// <summary>Opens a file-transfer session on the chosen host.</summary>
    private async Task ConnectToHostAsync(CancellationToken cancellationToken)
    {
        if (vault is not { } open || SelectedHost is not { } row)
        {
            Status = "Choose a host first.";
            return;
        }

        if (!open.TryBuildConnectionRequest(
            row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
        {
            Status = refusal;
            return;
        }

        PendingHostKey = null;
        HostKeyMismatch = null;

        await RunAsync(
            $"Connecting to {row.Label}…",
            async () =>
            {
                await CloseSessionAsync().ConfigureAwait(true);

                ISftpSession opened;

                try
                {
                    opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
                }
                catch (SshHostKeyUnknownException exception)
                {
                    // First contact, decided here rather than inherited from a terminal. File transfer is
                    // its own connection, so it makes its own trust decision — and the pin it writes is the
                    // same pin a shell would then find.
                    PendingHostKey = exception.Presentation;
                    Status = "This host has not been seen before.";
                    return;
                }
                catch (SshHostKeyMismatchException exception)
                {
                    HostKeyMismatch = exception.Message;
                    Status = "The host key has changed. Nothing was connected.";
                    return;
                }

                session = opened;
                TypedPassword = string.Empty;
                MarkHostConnected(opened, request, row, identityLabel);

                await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);

                Status = $"Connected to {row.Label}.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
    /// disconnect log closes out later.
    /// </summary>
    /// <remarks>
    /// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
    /// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
    /// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
    /// as <c>IRemoteFileStore</c> and does not carry either.
    /// </remarks>
    private void MarkHostConnected(
        ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
    {
        IsConnected = true;
        ConnectedTo = string.Create(
            CultureInfo.InvariantCulture,
            $"{request.Username}@{request.Host}:{request.Port}");
        ConnectedCipher = opened.Cipher;
        ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
        ConnectedIdentityLabel = identityLabel;

        // See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow.
        ConnectedPinnedPaths.Clear();
        foreach (var path in row.Host.PinnedPaths)
        {
            ConnectedPinnedPaths.Add(path);
        }

        OnPropertyChanged(nameof(HasConnectedPins));

        // Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
        // far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
        // the host's — and anybody comparing the two would be right to believe the host.
        connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());

        // Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
        // and ConnectedCipher second — a partial method firing off the first assignment would read
        // HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
        // there. Only at the end of this method are both facts actually true together.
        OnPropertyChanged(nameof(HasLiveFileSession));
        ActivityChanged?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>
    /// Closes the file-transfer session.
    /// </summary>
    /// <remarks>
    /// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
    /// a transfer is a decision about somebody's file; a button that did both would make the second one by
    /// accident.
    /// </remarks>
    [RelayCommand]
    private async Task DisconnectAsync()
    {
        if (queue.IsBusy)
        {
            Status = "There are transfers still running. Stop them first, or let them finish.";
            return;
        }

        await CloseSessionAsync().ConfigureAwait(true);

        Status = "Disconnected. Anything already transferred is where it landed.";
    }

    /// <summary>Pins the offered host key and connects.</summary>
    [RelayCommand]
    private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
    {
        if (PendingHostKey is not { } presentation || knownHosts is null)
        {
            return;
        }

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

        PendingHostKey = null;

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

    /// <summary>
    /// Dismisses whichever host key card is showing, without pinning or forgetting anything.
    /// </summary>
    /// <remarks>
    /// One command for both cards, because both are dismissals and the two states are mutually exclusive.
    /// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
    /// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
    /// with two clicks instead of one.
    /// </remarks>
    [RelayCommand]
    private void RejectHostKey()
    {
        var wasOffered = PendingHostKey is not null;

        PendingHostKey = null;
        HostKeyMismatch = null;

        Status = wasOffered
            ? "The host key was not trusted, so nothing was connected."
            : "Nothing was connected.";
    }

    // ---- Navigation ----

    /// <summary>Goes to a remote directory.</summary>
    [RelayCommand]
    private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
    {
        await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Goes up one remote directory.</summary>
    [RelayCommand]
    private async Task RemoteUpAsync(CancellationToken cancellationToken)
    {
        if (RemotePath.Length > 0)
        {
            await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>Re-reads the remote directory.</summary>
    [RelayCommand]
    private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
    {
        if (RemotePath.Length > 0)
        {
            await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>
    /// Opens whatever is selected in the remote pane, if it is somewhere to go.
    /// </summary>
    /// <remarks>
    /// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
    /// directory reports as a link and resolving every one of them would be a round trip per row. The
    /// failure, when it is a link to a file, is the server's own and says so.
    /// </remarks>
    [RelayCommand]
    private async Task OpenRemoteAsync(CancellationToken cancellationToken)
    {
        if (SelectedRemoteEntry is { IsNavigable: true } row)
        {
            await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>Goes to a local directory.</summary>
    [RelayCommand]
    private void GoLocal(string path) => NavigateLocal(path);

    /// <summary>Goes up one local directory, as far as the top of the drive.</summary>
    /// <remarks>
    /// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
    /// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
    /// </remarks>
    [RelayCommand]
    private void LocalUp()
    {
        if (LocalDirectory.Parent(LocalPath) is { } parent)
        {
            NavigateLocal(parent);
            return;
        }

        Status = "That is the top of this drive. Use the drive list to go to another one.";
    }

    /// <summary>Re-reads the local directory.</summary>
    [RelayCommand]
    private void RefreshLocal() => NavigateLocal(LocalPath);

    /// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
    [RelayCommand]
    private void OpenLocal()
    {
        if (SelectedLocalEntry is { IsNavigable: true } row)
        {
            NavigateLocal(row.FullPath);
        }
    }

    // ---- Moving files ----

    /// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
    [RelayCommand]
    private void Download()
    {
        if (SelectedRemoteEntry is not { } row)
        {
            Status = "Choose a file on the host to download.";
            return;
        }

        QueueDownloads([row]);
    }

    /// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
    [RelayCommand]
    private void Upload()
    {
        if (SelectedLocalEntry is not { } row)
        {
            Status = "Choose a file on this machine to upload.";
            return;
        }

        QueueUploads([row.FullPath]);
    }

    /// <summary>
    /// Queues every one of these local paths for upload into the remote directory showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The one path both the button and a drop go through, so there is one set of rules about what can be
    /// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
    /// were dragged, from this window's own pane or from the file manager.
    /// </para>
    /// <para>
    /// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
    /// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
    /// </para>
    /// <para>
    /// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
    /// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
    /// about all five.
    /// </para>
    /// </remarks>
    internal void QueueUploads(IReadOnlyList<string> paths)
    {
        ArgumentNullException.ThrowIfNull(paths);

        if (!IsConnected)
        {
            Status = "Connect to a host first.";
            return;
        }

        var queued = 0;
        var directories = 0;
        var missing = 0;

        foreach (var path in paths)
        {
            if (Directory.Exists(path))
            {
                directories++;
                continue;
            }

            // Between the drag starting and the drop landing, a file can be moved or deleted — and the
            // paths in an OS drop come from another process, which is not obliged to be right about them.
            if (!File.Exists(path))
            {
                missing++;
                continue;
            }

            var length = new FileInfo(path).Length;
            var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));

            queue.Enqueue(TransferDirection.Upload, path, destination, length);
            queued++;
        }

        Status = Describe(queued, "upload into", RemotePath, directories, missing);
    }

    /// <summary>
    /// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
    /// the transfer no longer needs them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
    /// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
    /// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
    /// last attempt reached. So the head copies the document into the application's own cache first and
    /// hands over the copy, which is a real file that behaves like every other thing in this queue.
    /// </para>
    /// <para>
    /// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
    /// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
    /// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
    /// next launch by the head that made it, which is the only place that knows where it put it.
    /// </para>
    /// </remarks>
    internal void QueueStagedUploads(IReadOnlyList<string> paths)
    {
        ArgumentNullException.ThrowIfNull(paths);

        foreach (var path in paths)
        {
            staged.Add(path);
        }

        QueueUploads(paths);
    }

    /// <summary>
    /// Queues one download into a local file this application made, and hands the finished bytes to
    /// something that knows where they were really meant to go.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
    /// no directory a download could simply be written into: what the person chose is a document handed back
    /// by the system's save picker, which this layer cannot open and the queue could not resume against. So
    /// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
    /// that raised the picker — copies the result out once there is a result to copy.
    /// </para>
    /// <para>
    /// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
    /// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
    /// arrive while the application is in the background, where Android will not show it at all. The cost is
    /// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
    /// download that then fails leaves an empty file where it was pointed.
    /// </para>
    /// <para>
    /// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
    /// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
    /// thrown away at the one moment it is worth the most.
    /// </para>
    /// </remarks>
    /// <param name="row">The remote file to fetch.</param>
    /// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
    /// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
    internal void QueueDeliveredDownload(
        RemoteEntryRowViewModel row,
        string localPath,
        Func<string, Task> deliver)
    {
        ArgumentNullException.ThrowIfNull(row);
        ArgumentNullException.ThrowIfNull(deliver);

        if (!IsConnected)
        {
            Status = "Connect to a host first.";
            return;
        }

        if (!row.IsFile)
        {
            Status = "Only files can be transferred.";
            return;
        }

        staged.Add(localPath);
        deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
            deliver;

        Status = $"Queued {row.Name} for download.";
    }

    /// <remarks>
    /// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
    /// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
    /// status line is the only report either way, which is the same place every other outcome on this screen
    /// is reported.
    /// </remarks>
    private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
    {
        var name = Path.GetFileName(localPath);

        try
        {
            await deliver(localPath).ConfigureAwait(true);

            Status = $"Saved {name}.";
            ReleaseStaged(localPath);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
        }
    }

    /// <remarks>
    /// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
    /// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
    /// in it is not something this method is entitled to reason about. Failures are ignored rather than
    /// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
    /// a person could do with the news.
    /// </remarks>
    private void ReleaseStaged(string localPath)
    {
        if (!staged.Remove(localPath))
        {
            return;
        }

        try
        {
            File.Delete(localPath);

            if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
            {
                Directory.Delete(folder);
            }
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
        }
    }

    /// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
    /// <inheritdoc cref="QueueUploads" path="/remarks" />
    internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
    {
        ArgumentNullException.ThrowIfNull(rows);

        if (!IsConnected)
        {
            Status = "Connect to a host first.";
            return;
        }

        var queued = 0;
        var directories = 0;

        foreach (var row in rows)
        {
            if (!row.IsFile)
            {
                directories++;
                continue;
            }

            queue.Enqueue(
                TransferDirection.Download,
                Path.Combine(LocalPath, row.Name),
                row.FullPath,
                row.Entry.Length);

            queued++;
        }

        Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
    }

    /// <remarks>
    /// One sentence for both directions and every shape of partial success. What it must never do is stay
    /// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
    /// looking for the other two in a queue they are not in.
    /// </remarks>
    private static string Describe(int queued, string verb, string destination, int directories, int missing)
    {
        var files = queued == 1 ? "1 file" : $"{queued} files";
        var said = queued == 0
            ? "Nothing was queued."
            : $"Queued {files} for {verb} {destination}.";

        if (directories > 0)
        {
            var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
            said += $" {folders} skipped — only files can be transferred.";
        }

        if (missing > 0)
        {
            var gone = missing == 1 ? "1 item was" : $"{missing} items were";
            said += $" {gone} no longer there.";
        }

        return said;
    }

    /// <summary>Stops one transfer.</summary>
    [RelayCommand]
    private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);

    /// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
    [RelayCommand]
    private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);

    /// <summary>Removes one stopped transfer and whatever it left behind.</summary>
    [RelayCommand]
    private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
    {
        // Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
        // removed anyway would take the only view of a transfer that was still running.
        if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
        {
            Transfers.Remove(row);

            // Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
            // RESUME the staged copy was being kept for, and any delivery that was waiting on it.
            deliveries.Remove(row.Id);
            ReleaseStaged(row.Transfer.LocalPath);
        }
    }

    /// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
    [RelayCommand]
    private void ClearCompleted()
    {
        queue.ClearCompleted();

        foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
        {
            Transfers.Remove(row);
        }

    }

    // ---- Changing the remote directory ----

    /// <summary>Creates a directory on the host.</summary>
    [RelayCommand]
    private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
    {
        var name = NewRemoteFolder.Trim();

        if (name.Length == 0)
        {
            Status = "Type a name for the new directory.";
            return;
        }

        if (name.Contains('/', StringComparison.Ordinal))
        {
            // One directory, whose parent must exist. A name with a separator in it would be a request to
            // create a path, and this button creates a directory in the one on screen.
            Status = "A directory name cannot contain '/'. Make one level at a time.";
            return;
        }

        await RunAsync(
            $"Creating {name}…",
            async () =>
            {
                await RequireSession()
                    .CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
                    .ConfigureAwait(true);

                NewRemoteFolder = string.Empty;

                await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);

                Status = $"Created {name}.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Asks whether the chosen remote file, or empty directory, should go.
    /// </summary>
    /// <remarks>
    /// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
    /// is in the way, "that file is already there" would be a dead end. It is asked about first because of
    /// what it is — the only thing this application destroys that neither the server nor this machine has a
    /// copy of.
    /// </remarks>
    [RelayCommand]
    private void DeleteRemote()
    {
        if (SelectedRemoteEntry is not { } row)
        {
            Status = "Choose something on the host to delete.";
            return;
        }

        PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
    }

    /// <summary>
    /// Deletes what was agreed to.
    /// </summary>
    /// <remarks>
    /// Not recursive, and the refusal comes from the server rather than from a check here — see
    /// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
    /// which is what makes the question a promise: nothing between asking and answering can point it
    /// somewhere else.
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
    {
        if (PendingRemoteDeletion is not { } request)
        {
            return;
        }

        PendingRemoteDeletion = null;

        await RunAsync(
            $"Deleting {request.Name}…",
            async () =>
            {
                await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);

                await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);

                Status = $"Deleted {request.Name}.";
            }).ConfigureAwait(true);
    }

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

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

        disposed = true;

        queue.Changed -= OnTransferChanged;

        // The queue first: it holds the session, and a transfer still writing into a part file has to finish
        // unwinding before the transport under it goes.
        await queue.DisposeAsync().ConfigureAwait(false);

        if (session is not null)
        {
            await session.DisposeAsync().ConfigureAwait(false);
            session = null;
        }
    }

    /// <summary>
    /// Goes to a remote directory, on its own.
    /// </summary>
    /// <remarks>
    /// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
    /// something else — connecting, making a directory, deleting one — can list without going back through
    /// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
    /// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
    /// anywhere to explain it.
    /// </remarks>
    private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
    {
        if (session is null)
        {
            Status = "Connect to a host first.";
            return Task.CompletedTask;
        }

        return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
    }

    private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
    {
        var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);

        RemotePath = path;
        SelectedRemoteEntry = null;

        RemoteEntries.Clear();

        foreach (var entry in entries)
        {
            RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
        }

        RemoteTrail.Clear();

        foreach (var (name, crumb) in SftpPath.Trail(path))
        {
            RemoteTrail.Add(new CrumbViewModel(name, crumb));
        }


        Status = string.Empty;
    }

    /// <remarks>
    /// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
    /// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
    /// something that returns before the click has finished being handled.
    /// </remarks>
    private void NavigateLocal(string path)
    {
        try
        {
            var entries = LocalDirectory.List(path);

            LocalPath = Path.GetFullPath(path);
            SelectedLocalEntry = null;

            LocalEntries.Clear();

            foreach (var entry in entries)
            {
                LocalEntries.Add(new LocalEntryRowViewModel(entry));
            }

            RebuildLocalTrail();

        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
            // The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
            // pane would look like a directory that had become empty.
            Status = $"{path} could not be read: {exception.Message}";
        }
    }

    /// <remarks>
    /// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
    /// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
    /// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
    /// </remarks>
    private void RebuildLocalTrail()
    {
        var crumbs = new List<CrumbViewModel>();

        for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
        {
            crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
        }

        LocalTrail.Clear();

        foreach (var crumb in crumbs)
        {
            LocalTrail.Add(crumb);
        }
    }

    private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();

    /// <summary>
    /// Rebuilds both pickers from the vault's lists.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts
    /// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first.
    /// </para>
    /// <para>
    /// <b>The selection is re-found by id rather than kept.</b> The vault replaces every row on every reload,
    /// which a synchronisation pass does once a minute, so holding the object would leave the picker showing
    /// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also
    /// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be
    /// connected to.
    /// </para>
    /// </remarks>
    private void RefreshHosts()
    {
        var host = SelectedHost?.EntityId;
        var bucket = SelectedBucket?.EntityId;

        Hosts.Clear();

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

        foreach (var row in open.Hosts)
        {
            Hosts.Add(row);
        }

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

        Buckets.Clear();

        foreach (var row in open.ObjectStores)
        {
            Buckets.Add(row);
        }

        SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
    }

    /// <summary>The session, or a failure a queue row can carry.</summary>
    private IRemoteFileStore RequireSession() =>
        session ?? throw new InvalidOperationException(
            "This screen is not connected to a host, so there is nowhere to move the file.");

    private async Task CloseSessionAsync()
    {
        if (session is { } open)
        {
            session = null;
            await open.DisposeAsync().ConfigureAwait(true);
        }

        // Written whole here rather than through an open/close ticket, because this connection is not one
        // the terminal workspace ever knew about — it has no session id, and borrowing one would collide
        // with a real terminal's.
        if (connected is { } record)
        {
            connected = null;

            connectionLog?.Record(
                record.Address,
                record.HostLabel,
                record.HostId,
                ConnectionKind.Sftp,
                record.StartedAt,
                TimeProvider.System.GetUtcNow(),
                ConnectionOutcome.Closed);
        }

        IsConnected = false;
        ConnectedTo = null;
        ConnectedCipher = null;
        ConnectedHostKeyAlgorithm = null;
        ConnectedIdentityLabel = null;
        ConnectedPinnedPaths.Clear();
        OnPropertyChanged(nameof(HasConnectedPins));
        RemotePath = string.Empty;
        RemoteEntries.Clear();
        RemoteTrail.Clear();
        SelectedRemoteEntry = null;

        // Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
        // already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
        // covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
        OnPropertyChanged(nameof(HasLiveFileSession));
        ActivityChanged?.Invoke(this, EventArgs.Empty);
    }

    /// <remarks>
    /// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
    /// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
    /// instead of one for the first change and one for the rest.
    /// </remarks>
    private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
        post(() =>
        {
            // Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
            // button that will read this file again.
            if (e.Transfer.State is TransferState.Completed)
            {
                // A staged download is not finished when the queue says so — it is finished when the bytes
                // reach the document the person picked, and only the head can put them there. So the copy
                // is released by the delivery rather than here, or it would be deleted on the way.
                if (deliveries.Remove(e.Transfer.Id, out var deliver))
                {
                    _ = DeliverAsync(e.Transfer.LocalPath, deliver);
                }
                else
                {
                    ReleaseStaged(e.Transfer.LocalPath);
                }
            }

            if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
            {
                existing.Transfer = e.Transfer;
            }
            else
            {
                Transfers.Add(new TransferRowViewModel(e.Transfer));
            }

            // Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
            // rows count as active without the collection itself gaining or losing a row, so the strip's own
            // count chip needs its own raise here.
            OnPropertyChanged(nameof(ActiveTransfersLabel));

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

    /// <remarks>
    /// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
    /// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
    /// pane permanently disabled.
    /// </remarks>
    private async Task RunAsync(string busyMessage, Func<Task> work)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;
        Status = busyMessage;

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

    partial void OnSelectedHostChanged(HostRowViewModel? value) =>
        OnPropertyChanged(nameof(SelectedHostAsksForAPassword));

    /// <remarks>
    /// The password box follows this as well as the host, because it is shown only for a host that asks for
    /// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
    /// picker that has nothing to do with passwords.
    /// </remarks>
    partial void OnRemoteChanged(RemoteKind value)
    {
        OnPropertyChanged(nameof(ShowsHostPicker));
        OnPropertyChanged(nameof(ConnectLabel));
        OnPropertyChanged(nameof(SelectedHostAsksForAPassword));

        RaiseBucketState();

        // Arriving at the other destination shows its invitation rather than a picker somebody left open on
        // this one — and the picker it would have left open is the wrong one, since the two kinds have
        // different lists behind them.
        IsChoosingRemote = false;
    }

    /// <summary>Raises the three flags that answer "is there a bucket to open".</summary>
    /// <remarks>
    /// Together, because they are one fact read three ways and a caller that raised two of them would leave
    /// an invitation and an empty state on screen at once.
    /// </remarks>
    private void RaiseBucketState()
    {
        OnPropertyChanged(nameof(ShowsBucketPicker));
        OnPropertyChanged(nameof(HasBuckets));
        OnPropertyChanged(nameof(ShowsBucketChoice));
        OnPropertyChanged(nameof(ShowsNoBuckets));
    }

    partial void OnIsConnectedChanged(bool value)
    {
        OnPropertyChanged(nameof(CanDownload));
        OnPropertyChanged(nameof(CanUpload));
        OnPropertyChanged(nameof(CanDeleteRemote));

        // Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
        // back to its invitation rather than to the form, so what the pane shows after a disconnect is the
        // same thing it showed before anything was ever connected.
        IsChoosingRemote = false;
    }

    /// <remarks>
    /// Any change to the selection takes the question away, which is stricter than the vault's rule and can
    /// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
    /// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
    /// selection, so this one hook covers all three ways the answer could stop being about what was asked.
    /// </remarks>
    partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
    {
        OnPropertyChanged(nameof(CanDownload));

        PendingRemoteDeletion = null;
    }

    partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
    {
        OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
        OnPropertyChanged(nameof(CanDeleteRemote));
    }

    partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
        OnPropertyChanged(nameof(CanUpload));

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

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