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; /// What sort of remote the file browser's right-hand pane is showing. internal enum RemoteKind { /// A host, over SFTP. Host, /// An S3-compatible bucket. Bucket, } /// One segment of a path, as a button in a breadcrumb trail. /// What the segment is called. /// The absolute path that reaches it. internal sealed record CrumbViewModel(string Name, string Path); /// One remote file or directory, as a row. 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; /// /// 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. /// internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty; /// /// 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 /// 0001-01-01 00:00, 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. /// internal string Modified => entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc); /// The mode as drwxr-xr-x, which is the design's PERMS column. internal string Permissions => entry.Permissions; /// Whether the row is a file with an execute bit, which the NAME column colours for. internal bool IsExecutable => entry.IsExecutable; /// Whether the row is a file anyone may write to, which the PERMS column colours for. internal bool IsWorldWritable => entry.IsWorldWritable; } /// One local file or directory, as a row. /// /// 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 -rw-r--r--. /// 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; internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length); internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc); } /// How this screen writes a modification time. /// /// /// 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. /// /// /// The one place in this application that deliberately ignores the user's locale — see the App project's /// InvariantGlobalization, 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. /// /// internal static class Timestamps { internal static string Format(DateTimeOffset moment) => moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } /// One transfer, as a row in the queue. /// /// 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 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. /// internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject { [ObservableProperty] private TransferSnapshot transfer = snapshot; internal Guid Id => Transfer.Id; internal string Name => Transfer.Name; /// Which way, as an arrow the eye can scan a column of. internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑"; /// The end that is not this machine, which is the one worth showing. internal string Path => Transfer.Direction is TransferDirection.Download ? Transfer.RemotePath : Transfer.LocalPath; internal double Percent => Transfer.Fraction * 100; /// /// What the row says about where it has got to. /// /// /// 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. /// 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; /// Whether a stopped transfer is worth offering to run again at all. /// /// Wider than : 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 . /// internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed; internal string RetryLabel => CanResume ? "RESUME" : "RETRY"; /// /// 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". /// partial void OnTransferChanged(TransferSnapshot value) { OnPropertyChanged(nameof(Progress)); OnPropertyChanged(nameof(Percent)); OnPropertyChanged(nameof(StateLabel)); OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsFinished)); OnPropertyChanged(nameof(CanResume)); OnPropertyChanged(nameof(CanRetry)); OnPropertyChanged(nameof(HasFailed)); OnPropertyChanged(nameof(RetryLabel)); } } /// /// Something on the host that has been asked about and not yet agreed to. /// /// /// /// 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. /// /// /// It carries the full path rather than only the name, because the name is the half that does not identify /// anything: config in the directory that was showing a moment ago and config in the one /// showing now look identical in a confirmation, and only one of them is the file somebody meant. /// /// /// What the row was called. /// Where it is, which is what the question actually promises to delete. /// Whether it is a directory, which the host treats differently. internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory) { /// The question, naming the kind because the two behave differently. internal string Question => IsDirectory ? $"Delete the directory '{Name}' on the host?" : $"Delete '{Name}' on the host?"; /// What it costs, which is everything: there is no copy here and no undo there. 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."; } /// /// The transfers screen: a host, two directory panes, and the queue between them. /// /// /// /// Its connection is its own. SSH.NET cannot open an SFTP subsystem on a transport that is already /// carrying a shell — see ISftpSession — 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. /// /// /// It outlives a lock, as terminals do. This object is created once and the vault is attached to it /// on unlock and detached on lock, the same arrangement VaultKnownHostStore has and for the same /// reason: MainWindowViewModel.LockAsync 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. /// /// internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable { private readonly ISftpSessionFactory sftp; private readonly FileTransferQueue queue; private readonly Action post; /// /// Local files that exist only so this queue could move them — see and /// . /// /// /// 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. /// private readonly HashSet staged = new(StringComparer.OrdinalIgnoreCase); /// /// What to do with a completed download whose real destination this layer cannot write to. /// /// /// 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 . /// private readonly Dictionary> deliveries = []; private VaultViewModel? vault; private VaultKnownHostStore? knownHosts; private IRemoteFileStore? session; private ConnectionRecorder? connectionLog; /// How a bucket is opened, or null in a build that was not given one. private IObjectStoreFactory? objectStores; /// The open SFTP connection, as the log will record it, or null when there is none. /// /// 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. /// private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected; private bool disposed; /// Opens SFTP sessions. /// Time source, for transfer rates and timestamps. /// /// 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. /// /// A delegate rather than Dispatcher.UIThread reached directly, for the reason /// VaultViewModel's clipboard is one. Dispatcher.UIThread 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. /// /// /// /// Takes the user to where a bucket is made, or null in a head that has nowhere to take them. /// /// 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. /// /// internal TransfersViewModel( ISftpSessionFactory sftp, TimeProvider clock, 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(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)); // 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(); } /// Where a bucket is made, or null in a head with nowhere to send anybody. private readonly Action? addBucket; /// Goes to the keychain with the bucket editor open. /// /// 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. /// [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(); } /// The hosts that can be connected to, which is the vault's list. /// /// 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. /// internal ObservableCollection Hosts { get; } = []; [ObservableProperty] private HostRowViewModel? selectedHost; /// The buckets that can be browsed, which is the vault's list. /// internal ObservableCollection Buckets { get; } = []; [ObservableProperty] private ObjectStoreRowViewModel? selectedBucket; /// /// Whether there is a bucket to open at all. /// /// /// 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 . /// internal bool HasBuckets => Buckets.Count > 0; /// Whether this screen is on S3 with a bucket to offer. /// /// 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 IsVisible expressions that have to stay each other's opposites. /// internal bool ShowsBucketChoice => ShowsBucketPicker && HasBuckets; /// Whether this screen is on S3 with nothing to open yet. internal bool ShowsNoBuckets => ShowsBucketPicker && !HasBuckets; /// /// Which sort of remote the right-hand pane is about to open. /// /// /// /// 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. /// /// /// 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. /// /// [ObservableProperty] private RemoteKind remote; /// Whether the picker is showing hosts. internal bool ShowsHostPicker => Remote is RemoteKind.Host; /// Whether the picker is showing buckets. internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket; /// /// Whether the picker is open, as opposed to the invitation that offers it. /// /// /// /// 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. /// /// /// 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 is what it shows before a connection exists. /// /// /// 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. /// /// [ObservableProperty] private bool isChoosingRemote; /// /// What the button that opens the remote says. /// /// /// "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. /// internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT"; /// /// 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. /// [ObservableProperty] private string typedPassword = string.Empty; /// /// 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. /// [ObservableProperty] private string status = "Nothing is open yet."; [ObservableProperty] private bool isBusy; [ObservableProperty] private bool isConnected; /// /// Whether something is being dragged over the local pane, and whether it would be accepted. /// /// /// 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. /// [ObservableProperty] private bool isLocalDropTarget; /// [ObservableProperty] private bool isRemoteDropTarget; /// [ObservableProperty] private bool isRemoteDropRefused; /// The account and endpoint actually dialled, once connected. [ObservableProperty] private string? connectedTo; [ObservableProperty] private HostKeyPresentation? pendingHostKey; [ObservableProperty] private string? hostKeyMismatch; internal bool HasPendingHostKey => PendingHostKey is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null; /// Whether the chosen host will want something typed into the password box. /// /// The resolved binding, for the reason its counterpart on VaultViewModel 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. /// 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 RemoteEntries { get; } = []; internal ObservableCollection RemoteTrail { get; } = []; [ObservableProperty] private RemoteEntryRowViewModel? selectedRemoteEntry; /// What a new directory would be called, when the user is making one. [ObservableProperty] private string newRemoteFolder = string.Empty; internal bool HasRemoteEntries => RemoteEntries.Count > 0; /// The deletion on the host that has been asked about, or null when none has. [ObservableProperty] private RemoteDeletionRequest? pendingRemoteDeletion; internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null; /// Whether the pane's DELETE is live. /// /// 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. /// internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion; // ---- The local pane ---- [ObservableProperty] private string localPath = LocalDirectory.Home; internal ObservableCollection LocalEntries { get; } = []; internal ObservableCollection LocalTrail { get; } = []; /// /// The drives this machine has, as somewhere the local pane can jump to. /// /// /// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one /// does not: above 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 D: would /// be unreachable from an application whose whole purpose on this screen is to move one. /// internal ObservableCollection LocalRoots { get; } = []; [ObservableProperty] private LocalEntryRowViewModel? selectedLocalEntry; internal bool HasLocalEntries => LocalEntries.Count > 0; // ---- The queue ---- internal ObservableCollection Transfers { get; } = []; /// Raised on the UI thread whenever a transfer appears or changes state. /// /// 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 and down afterwards. An event rather than /// letting that head watch 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. /// internal event EventHandler? ActivityChanged; /// How many transfers are moving or waiting to move. /// /// 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. /// internal int ActiveTransfers => Transfers.Count(row => row.IsRunning); internal bool HasTransfers => Transfers.Count > 0; /// Whether a download of the chosen remote file would have somewhere to go. internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true }; /// Whether an upload of the chosen local file would have somewhere to go. internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true }; /// Takes an unlocked vault, so the host list has something in it. /// The open keychain. /// The pins this screen's own trust decisions are written to. /// /// 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. /// 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(root.TrimEnd(Path.DirectorySeparatorChar), root)); } RefreshLocalCommand.Execute(null); } /// /// Gives up the vault, keeping the connection and anything in flight. /// /// /// 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. /// 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. /// Opens the picker, from the invitation in the empty remote pane. [RelayCommand] private void BeginChoosingRemote() => IsChoosingRemote = true; /// /// Puts the picker away without connecting. /// /// /// 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. /// [RelayCommand] private void CancelChoosingRemote() { IsChoosingRemote = false; TypedPassword = string.Empty; } /// Opens the chosen remote, whichever kind it is. [RelayCommand] private Task ConnectAsync(CancellationToken cancellationToken) => Remote is RemoteKind.Bucket ? OpenBucketAsync(cancellationToken) : ConnectToHostAsync(cancellationToken); /// /// Opens the chosen bucket. /// /// /// /// 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. /// /// /// It goes through the same session field, the same queue and the same panes as a host, because by this /// point it is an IRemoteFileStore like any other. Everything below this method was written for /// SFTP and needed no change. /// /// 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}"); connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow()); await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true); Status = $"Opened {row.Label}."; }).ConfigureAwait(true); } /// Opens a file-transfer session on the chosen host. 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 refusal)) { Status = refusal; return; } PendingHostKey = null; HostKeyMismatch = null; await RunAsync( $"Connecting to {row.Label}…", async () => { await CloseSessionAsync().ConfigureAwait(true); try { session = 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; } TypedPassword = string.Empty; IsConnected = true; ConnectedTo = string.Create( CultureInfo.InvariantCulture, $"{request.Username}@{request.Host}:{request.Port}"); // 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()); await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true); Status = $"Connected to {row.Label}."; }).ConfigureAwait(true); } /// /// Closes the file-transfer session. /// /// /// 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. /// [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."; } /// Pins the offered host key and connects. [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); } /// /// Dismisses whichever host key card is showing, without pinning or forgetting anything. /// /// /// 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. /// [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 ---- /// Goes to a remote directory. [RelayCommand] private async Task GoRemoteAsync(string path, CancellationToken cancellationToken) { await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true); } /// Goes up one remote directory. [RelayCommand] private async Task RemoteUpAsync(CancellationToken cancellationToken) { if (RemotePath.Length > 0) { await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true); } } /// Re-reads the remote directory. [RelayCommand] private async Task RefreshRemoteAsync(CancellationToken cancellationToken) { if (RemotePath.Length > 0) { await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); } } /// /// Opens whatever is selected in the remote pane, if it is somewhere to go. /// /// /// A symbolic link is tried as a directory: a listing carries lstat 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. /// [RelayCommand] private async Task OpenRemoteAsync(CancellationToken cancellationToken) { if (SelectedRemoteEntry is { IsNavigable: true } row) { await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true); } } /// Goes to a local directory. [RelayCommand] private void GoLocal(string path) => NavigateLocal(path); /// Goes up one local directory, as far as the top of the drive. /// /// Above a drive root there is no directory to list, so this stops there and says so. Getting to another /// drive is , which is a list of places rather than a step upwards. /// [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."; } /// Re-reads the local directory. [RelayCommand] private void RefreshLocal() => NavigateLocal(LocalPath); /// Opens whatever is selected in the local pane, if it is a directory. [RelayCommand] private void OpenLocal() { if (SelectedLocalEntry is { IsNavigable: true } row) { NavigateLocal(row.FullPath); } } // ---- Moving files ---- /// Queues the chosen remote file for download into the local directory showing. [RelayCommand] private void Download() { if (SelectedRemoteEntry is not { } row) { Status = "Choose a file on the host to download."; return; } QueueDownloads([row]); } /// Queues the chosen local file for upload into the remote directory showing. [RelayCommand] private void Upload() { if (SelectedLocalEntry is not { } row) { Status = "Choose a file on this machine to upload."; return; } QueueUploads([row.FullPath]); } /// /// Queues every one of these local paths for upload into the remote directory showing. /// /// /// /// 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. /// /// /// Directories are skipped and counted. 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. /// /// /// Reported per item, not per drop. 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. /// /// internal void QueueUploads(IReadOnlyList 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); } /// /// Queues copies that were made for this upload and belong to nothing else, so they are deleted once /// the transfer no longer needs them. /// /// /// /// This exists for the phone, and the copy is not an implementation detail that could be avoided. /// Android hands a chosen document over as a content:// 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. /// /// /// Released on success and on discard, never on failure. 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. /// /// internal void QueueStagedUploads(IReadOnlyList paths) { ArgumentNullException.ThrowIfNull(paths); foreach (var path in paths) { staged.Add(path); } QueueUploads(paths); } /// /// 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. /// /// /// /// The mirror of , and it exists for the same reason. 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 — supplied by the head /// that raised the picker — copies the result out once there is a result to copy. /// /// /// The destination is chosen before the transfer starts, not after. 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. /// /// /// Delivery failure does not delete the bytes. 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. /// /// /// The remote file to fetch. /// Where to stage it — a path the head owns and will sweep. /// Copies the staged file to wherever it was really meant to go. internal void QueueDeliveredDownload( RemoteEntryRowViewModel row, string localPath, Func 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."; } /// /// 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. /// private async Task DeliverAsync(string localPath, Func 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}"; } } /// /// 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. /// 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) { } } /// Queues every one of these remote entries for download into the local directory showing. /// internal void QueueDownloads(IReadOnlyList 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); } /// /// 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. /// 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; } /// Stops one transfer. [RelayCommand] private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id); /// Runs a stopped transfer again, resuming where there is something to resume from. [RelayCommand] private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id); /// Removes one stopped transfer and whatever it left behind. [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); } } /// Clears the finished transfers, which have nothing left on disk. [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 ---- /// Creates a directory on the host. [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); } /// /// Asks whether the chosen remote file, or empty directory, should go. /// /// /// 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. /// [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); } /// /// Deletes what was agreed to. /// /// /// Not recursive, and the refusal comes from the server rather than from a check here — see /// ISftpSession.DeleteAsync. 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. /// [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); } /// Thinks better of it. [RelayCommand] private void CancelDeleteRemote() => PendingRemoteDeletion = null; /// 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; } } /// /// Goes to a remote directory, on its own. /// /// /// Split from so that the commands which navigate as part of /// something else — connecting, making a directory, deleting one — can list without going back through /// the busy guard. 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. /// 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; } /// /// 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. /// 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}"; } } /// /// Built by walking up rather than by splitting on the separator, because a Windows path's first segment /// is C:\ — a root with a separator inside it, which splitting turns into a crumb called /// C: that navigates to the process's current directory on that drive rather than to its root. /// private void RebuildLocalTrail() { var crumbs = new List(); 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(); /// /// Rebuilds both pickers from the vault's lists. /// /// /// /// 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. /// /// /// The selection is re-found by id rather than kept. 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. /// /// 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(); } /// The session, or a failure a queue row can carry. 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; RemotePath = string.Empty; RemoteEntries.Clear(); RemoteTrail.Clear(); SelectedRemoteEntry = null; } /// /// 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. /// 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)); } ActivityChanged?.Invoke(this, EventArgs.Empty); }); /// /// The same funnel VaultViewModel 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. /// private async Task RunAsync(string busyMessage, Func 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)); /// /// 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. /// 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; } /// Raises the three flags that answer "is there a bucket to open". /// /// 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. /// 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; } /// /// 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. /// 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)); }