using System.Collections.ObjectModel; using System.Globalization; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Transfer; namespace DodoSSH.Client.App.ViewModels; /// 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; internal string Modified => 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 VaultViewModel? vault; private VaultKnownHostStore? knownHosts; private ISftpSession? session; private bool disposed; internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock) { this.sftp = sftp; // 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 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 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; [ObservableProperty] private string status = "Choose a host and connect to browse its files."; [ObservableProperty] private bool isBusy; [ObservableProperty] private bool isConnected; /// 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. internal bool SelectedHostAsksForAPassword => SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } }; // ---- 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; } = []; 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. internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys) { vault = openVault; knownHosts = hostKeys; 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() { vault = null; knownHosts = null; Hosts.Clear(); SelectedHost = null; TypedPassword = string.Empty; } /// Opens a file-transfer session on the chosen host. [RelayCommand] private async Task ConnectAsync(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}"); 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 { IsFile: true } row) { Status = "Choose a file on the host to download."; return; } var destination = Path.Combine(LocalPath, row.Name); queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length); Status = $"Queued {row.Name} for download into {LocalPath}."; } /// Queues the chosen local file for upload into the remote directory showing. [RelayCommand] private void Upload() { if (SelectedLocalEntry is not { IsFile: true } row) { Status = "Choose a file on this machine to upload."; return; } var destination = SftpPath.Combine(RemotePath, row.Name); queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length); Status = $"Queued {row.Name} for upload into {RemotePath}."; } /// 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); } } /// 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 RefreshHosts() { Hosts.Clear(); if (vault is not { } open) { return; } foreach (var host in open.Hosts) { Hosts.Add(host); } SelectedHost ??= Hosts.FirstOrDefault(); } /// The session, or a failure a queue row can carry. private ISftpSession 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); } 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) => Dispatcher.UIThread.Post(() => { if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing) { existing.Transfer = e.Transfer; return; } Transfers.Add(new TransferRowViewModel(e.Transfer)); }); /// /// 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)); partial void OnIsConnectedChanged(bool value) { OnPropertyChanged(nameof(CanDownload)); OnPropertyChanged(nameof(CanUpload)); OnPropertyChanged(nameof(CanDeleteRemote)); } /// /// 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)); }