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));
}