Public Access
ci / build and test (push) Failing after 2s
The remote pane's NAME column was blue for a directory and plain for everything else, and the PERMS column was faint whatever it said. Two colours now come off the mode, split across those two columns on purpose: NAME says what a row is, so a file with an execute bit is green there, and PERMS says what is notable about how it is set, so a file anyone may write to is amber over the characters that actually say so. Because the two never compete for one TextBlock, a world-writable executable shows both facts instead of one winning an argument. No new blue is spent, which is what App.axaml asks for: it reserves blue for a directory, a distinct scope, and calls it deliberately rare. Both are files only, and each exclusion is a wrong answer avoided rather than a case not got to. Every symbolic link is lrwxrwxrwx by convention and its mode governs nothing — what may be written is the target, whose mode an lstat listing never fetched — so amber there would fire on every link on the host. A world-writable directory is /tmp, made safe by a sticky bit PosixMode does not render, and warning about it would be warning about the half of the mode that is on screen while the half that answers the warning is not. And the execute bit on a directory means "may be searched", which is true of very nearly every directory a host has, so green there would paint the whole pane and mark nothing. The two questions read back the string PosixMode wrote rather than carrying its nine booleans through SftpEntry as well. That is the point rather than a shortcut: two representations of one fact is how a row ends up coloured for a bit the column beside it does not show. A mode of the wrong length answers false rather than throwing, since these decide a colour and a listing is not worth failing over one. The amber is Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px against TextFaint that one is a shade rather than a signal, and a marker nobody notices is the same as no marker. The local pane is untouched, on the grounds it already gives for having no PERMS column at all: a POSIX mode is not a fact about a file on Windows, and colouring one there would invent exactly what the column declines to print. Twenty cases in RemotePathTests, which needs no container — the execute bit in any of the three triples rather than only the owner's, the others-write bit alone, a mode of the wrong length, and the file-only rule for both questions from all three kinds. dotnet format is clean and the app and layout suites pass at 109 and 35.
1034 lines
38 KiB
C#
1034 lines
38 KiB
C#
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;
|
|
|
|
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
|
/// <param name="Name">What the segment is called.</param>
|
|
/// <param name="Path">The absolute path that reaches it.</param>
|
|
internal sealed record CrumbViewModel(string Name, string Path);
|
|
|
|
/// <summary>One remote file or directory, as a row.</summary>
|
|
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
|
{
|
|
internal SftpEntry Entry => entry;
|
|
|
|
internal string Name => entry.Name;
|
|
|
|
internal string FullPath => entry.FullPath;
|
|
|
|
internal bool IsNavigable => entry.IsNavigable;
|
|
|
|
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
|
|
|
/// <remarks>
|
|
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
|
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
|
/// </remarks>
|
|
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
|
|
|
|
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
|
|
|
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
|
internal string Permissions => entry.Permissions;
|
|
|
|
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
|
|
internal bool IsExecutable => entry.IsExecutable;
|
|
|
|
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
|
|
internal bool IsWorldWritable => entry.IsWorldWritable;
|
|
}
|
|
|
|
/// <summary>One local file or directory, as a row.</summary>
|
|
/// <remarks>
|
|
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
|
|
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
|
|
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
|
|
/// </remarks>
|
|
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
|
{
|
|
internal LocalEntry Entry => entry;
|
|
|
|
internal string Name => entry.Name;
|
|
|
|
internal string FullPath => entry.FullPath;
|
|
|
|
internal bool IsNavigable => entry.IsDirectory;
|
|
|
|
internal bool IsFile => !entry.IsDirectory;
|
|
|
|
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
|
|
|
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
|
}
|
|
|
|
/// <summary>How this screen writes a modification time.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
|
|
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
|
|
/// not written the same way, which is the only thing anybody does with this column.
|
|
/// </para>
|
|
/// <para>
|
|
/// The one place in this application that deliberately ignores the user's locale — see the App project's
|
|
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
|
|
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class Timestamps
|
|
{
|
|
internal static string Format(DateTimeOffset moment) =>
|
|
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
/// <summary>One transfer, as a row in the queue.</summary>
|
|
/// <remarks>
|
|
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
|
|
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
|
|
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
|
|
/// only ever read from the UI thread.
|
|
/// </remarks>
|
|
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
|
|
{
|
|
[ObservableProperty]
|
|
private TransferSnapshot transfer = snapshot;
|
|
|
|
internal Guid Id => Transfer.Id;
|
|
|
|
internal string Name => Transfer.Name;
|
|
|
|
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
|
|
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
|
|
|
|
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
|
|
internal string Path => Transfer.Direction is TransferDirection.Download
|
|
? Transfer.RemotePath
|
|
: Transfer.LocalPath;
|
|
|
|
internal double Percent => Transfer.Fraction * 100;
|
|
|
|
/// <summary>
|
|
/// What the row says about where it has got to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
|
|
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
|
|
/// whether it is still moving.
|
|
/// </remarks>
|
|
internal string Progress => Transfer.State switch
|
|
{
|
|
TransferState.Queued => "queued",
|
|
TransferState.Running =>
|
|
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
|
|
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
|
|
TransferState.Completed => ByteSize.Format(Transfer.Length),
|
|
TransferState.Cancelled when Transfer.Transferred > 0 =>
|
|
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
|
|
TransferState.Cancelled => "stopped",
|
|
_ => Transfer.Failure ?? "failed",
|
|
};
|
|
|
|
internal string StateLabel => Transfer.State switch
|
|
{
|
|
TransferState.Queued => "QUEUED",
|
|
TransferState.Running => "RUNNING",
|
|
TransferState.Completed => "DONE",
|
|
TransferState.Cancelled => "STOPPED",
|
|
_ => "FAILED",
|
|
};
|
|
|
|
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
|
|
|
|
internal bool IsFinished => Transfer.IsFinished;
|
|
|
|
internal bool CanResume => Transfer.CanResume;
|
|
|
|
internal bool HasFailed => Transfer.State is TransferState.Failed;
|
|
|
|
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
|
|
/// <remarks>
|
|
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
|
|
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
|
|
/// wording differs. See <see cref="RetryLabel"/>.
|
|
/// </remarks>
|
|
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
|
|
|
|
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
|
|
|
|
/// <remarks>
|
|
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
|
|
/// raising only the ones that happened to change is how a progress bar moves under a label that still
|
|
/// says "queued".
|
|
/// </remarks>
|
|
partial void OnTransferChanged(TransferSnapshot value)
|
|
{
|
|
OnPropertyChanged(nameof(Progress));
|
|
OnPropertyChanged(nameof(Percent));
|
|
OnPropertyChanged(nameof(StateLabel));
|
|
OnPropertyChanged(nameof(IsRunning));
|
|
OnPropertyChanged(nameof(IsFinished));
|
|
OnPropertyChanged(nameof(CanResume));
|
|
OnPropertyChanged(nameof(CanRetry));
|
|
OnPropertyChanged(nameof(HasFailed));
|
|
OnPropertyChanged(nameof(RetryLabel));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Something on the host that has been asked about and not yet agreed to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
|
|
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
|
|
/// has no wastebasket to put them in.
|
|
/// </para>
|
|
/// <para>
|
|
/// It carries the full path rather than only the name, because the name is the half that does not identify
|
|
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
|
|
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="Name">What the row was called.</param>
|
|
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
|
|
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
|
|
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
|
|
{
|
|
/// <summary>The question, naming the kind because the two behave differently.</summary>
|
|
internal string Question => IsDirectory
|
|
? $"Delete the directory '{Name}' on the host?"
|
|
: $"Delete '{Name}' on the host?";
|
|
|
|
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
|
|
internal string Consequence => IsDirectory
|
|
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
|
|
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
|
|
+ "copy and there is no undo."
|
|
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
|
|
+ "touched, and there is no undo.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// The transfers screen: a host, two directory panes, and the queue between them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
|
|
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
|
|
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
|
|
/// The consequence a user sees is that the host records a second login, and that a host whose password is
|
|
/// typed each time asks for it again here.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
|
|
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
|
|
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
|
|
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
|
|
/// host list — those are decrypted vault items — and not the connection or the queue.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
|
|
{
|
|
private readonly ISftpSessionFactory sftp;
|
|
private readonly FileTransferQueue queue;
|
|
|
|
private 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));
|
|
}
|
|
|
|
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
|
|
/// <remarks>
|
|
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
|
|
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
|
/// be a second decrypted copy of a secret for no gain.
|
|
/// </remarks>
|
|
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private HostRowViewModel? selectedHost;
|
|
|
|
/// <remarks>
|
|
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
|
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
|
/// screen that quietly reused it would make a one-time password appear to work twice.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string typedPassword = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string status = "Choose a host and connect to browse its files.";
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
[ObservableProperty]
|
|
private bool isConnected;
|
|
|
|
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
|
[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;
|
|
|
|
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
|
internal bool SelectedHostAsksForAPassword =>
|
|
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
|
|
|
// ---- The remote pane ----
|
|
|
|
[ObservableProperty]
|
|
private string remotePath = string.Empty;
|
|
|
|
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
|
|
|
|
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
|
|
|
/// <summary>What a new directory would be called, when the user is making one.</summary>
|
|
[ObservableProperty]
|
|
private string newRemoteFolder = string.Empty;
|
|
|
|
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
|
|
|
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
|
|
[ObservableProperty]
|
|
private RemoteDeletionRequest? pendingRemoteDeletion;
|
|
|
|
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
|
|
|
/// <summary>Whether the pane's DELETE is live.</summary>
|
|
/// <remarks>
|
|
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
|
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
|
/// move UP and REFRESH out from under the pointer.
|
|
/// </remarks>
|
|
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
|
|
|
// ---- The local pane ----
|
|
|
|
[ObservableProperty]
|
|
private string localPath = LocalDirectory.Home;
|
|
|
|
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
|
|
|
|
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
|
|
|
|
/// <summary>
|
|
/// The drives this machine has, as somewhere the local pane can jump to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
|
|
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
|
|
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
|
|
/// be unreachable from an application whose whole purpose on this screen is to move one.
|
|
/// </remarks>
|
|
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private LocalEntryRowViewModel? selectedLocalEntry;
|
|
|
|
internal bool HasLocalEntries => LocalEntries.Count > 0;
|
|
|
|
// ---- The queue ----
|
|
|
|
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
|
|
|
internal bool HasTransfers => Transfers.Count > 0;
|
|
|
|
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
|
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
|
|
|
|
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
|
|
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
|
|
|
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gives up the vault, keeping the connection and anything in flight.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
|
|
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
|
|
/// </remarks>
|
|
internal void Detach()
|
|
{
|
|
vault = null;
|
|
knownHosts = null;
|
|
|
|
Hosts.Clear();
|
|
SelectedHost = null;
|
|
TypedPassword = string.Empty;
|
|
}
|
|
|
|
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the file-transfer session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
|
|
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
|
|
/// accident.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task DisconnectAsync()
|
|
{
|
|
if (queue.IsBusy)
|
|
{
|
|
Status = "There are transfers still running. Stop them first, or let them finish.";
|
|
return;
|
|
}
|
|
|
|
await CloseSessionAsync().ConfigureAwait(true);
|
|
|
|
Status = "Disconnected. Anything already transferred is where it landed.";
|
|
}
|
|
|
|
/// <summary>Pins the offered host key and connects.</summary>
|
|
[RelayCommand]
|
|
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (PendingHostKey is not { } presentation || knownHosts is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
|
return;
|
|
}
|
|
|
|
PendingHostKey = null;
|
|
|
|
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
|
|
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
|
|
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
|
|
/// with two clicks instead of one.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void RejectHostKey()
|
|
{
|
|
var wasOffered = PendingHostKey is not null;
|
|
|
|
PendingHostKey = null;
|
|
HostKeyMismatch = null;
|
|
|
|
Status = wasOffered
|
|
? "The host key was not trusted, so nothing was connected."
|
|
: "Nothing was connected.";
|
|
}
|
|
|
|
// ---- Navigation ----
|
|
|
|
/// <summary>Goes to a remote directory.</summary>
|
|
[RelayCommand]
|
|
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Goes up one remote directory.</summary>
|
|
[RelayCommand]
|
|
private async Task RemoteUpAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (RemotePath.Length > 0)
|
|
{
|
|
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Re-reads the remote directory.</summary>
|
|
[RelayCommand]
|
|
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (RemotePath.Length > 0)
|
|
{
|
|
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
|
|
/// directory reports as a link and resolving every one of them would be a round trip per row. The
|
|
/// failure, when it is a link to a file, is the server's own and says so.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (SelectedRemoteEntry is { IsNavigable: true } row)
|
|
{
|
|
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Goes to a local directory.</summary>
|
|
[RelayCommand]
|
|
private void GoLocal(string path) => NavigateLocal(path);
|
|
|
|
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
|
|
/// <remarks>
|
|
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
|
|
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void LocalUp()
|
|
{
|
|
if (LocalDirectory.Parent(LocalPath) is { } parent)
|
|
{
|
|
NavigateLocal(parent);
|
|
return;
|
|
}
|
|
|
|
Status = "That is the top of this drive. Use the drive list to go to another one.";
|
|
}
|
|
|
|
/// <summary>Re-reads the local directory.</summary>
|
|
[RelayCommand]
|
|
private void RefreshLocal() => NavigateLocal(LocalPath);
|
|
|
|
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
|
|
[RelayCommand]
|
|
private void OpenLocal()
|
|
{
|
|
if (SelectedLocalEntry is { IsNavigable: true } row)
|
|
{
|
|
NavigateLocal(row.FullPath);
|
|
}
|
|
}
|
|
|
|
// ---- Moving files ----
|
|
|
|
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
|
[RelayCommand]
|
|
private void Download()
|
|
{
|
|
if (SelectedRemoteEntry is not { 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}.";
|
|
}
|
|
|
|
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
|
[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}.";
|
|
}
|
|
|
|
/// <summary>Stops one transfer.</summary>
|
|
[RelayCommand]
|
|
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
|
|
|
|
/// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
|
|
[RelayCommand]
|
|
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
|
|
|
|
/// <summary>Removes one stopped transfer and whatever it left behind.</summary>
|
|
[RelayCommand]
|
|
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
|
|
{
|
|
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
|
|
// removed anyway would take the only view of a transfer that was still running.
|
|
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
|
{
|
|
Transfers.Remove(row);
|
|
}
|
|
}
|
|
|
|
/// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
|
|
[RelayCommand]
|
|
private void ClearCompleted()
|
|
{
|
|
queue.ClearCompleted();
|
|
|
|
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
|
|
{
|
|
Transfers.Remove(row);
|
|
}
|
|
|
|
}
|
|
|
|
// ---- Changing the remote directory ----
|
|
|
|
/// <summary>Creates a directory on the host.</summary>
|
|
[RelayCommand]
|
|
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
|
|
{
|
|
var name = NewRemoteFolder.Trim();
|
|
|
|
if (name.Length == 0)
|
|
{
|
|
Status = "Type a name for the new directory.";
|
|
return;
|
|
}
|
|
|
|
if (name.Contains('/', StringComparison.Ordinal))
|
|
{
|
|
// One directory, whose parent must exist. A name with a separator in it would be a request to
|
|
// create a path, and this button creates a directory in the one on screen.
|
|
Status = "A directory name cannot contain '/'. Make one level at a time.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
$"Creating {name}…",
|
|
async () =>
|
|
{
|
|
await RequireSession()
|
|
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
NewRemoteFolder = string.Empty;
|
|
|
|
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = $"Created {name}.";
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks whether the chosen remote file, or empty directory, should go.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
|
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
|
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
|
/// copy of.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteRemote()
|
|
{
|
|
if (SelectedRemoteEntry is not { } row)
|
|
{
|
|
Status = "Choose something on the host to delete.";
|
|
return;
|
|
}
|
|
|
|
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes what was agreed to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
|
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
|
|
/// which is what makes the question a promise: nothing between asking and answering can point it
|
|
/// somewhere else.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (PendingRemoteDeletion is not { } request)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingRemoteDeletion = null;
|
|
|
|
await RunAsync(
|
|
$"Deleting {request.Name}…",
|
|
async () =>
|
|
{
|
|
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
|
|
|
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = $"Deleted {request.Name}.";
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Thinks better of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
queue.Changed -= OnTransferChanged;
|
|
|
|
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
|
|
// unwinding before the transport under it goes.
|
|
await queue.DisposeAsync().ConfigureAwait(false);
|
|
|
|
if (session is not null)
|
|
{
|
|
await session.DisposeAsync().ConfigureAwait(false);
|
|
session = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Goes to a remote directory, on its own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
|
|
/// something else — connecting, making a directory, deleting one — can list without going back through
|
|
/// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
|
|
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
|
|
/// anywhere to explain it.
|
|
/// </remarks>
|
|
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
if (session is null)
|
|
{
|
|
Status = "Connect to a host first.";
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
|
}
|
|
|
|
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
|
|
|
RemotePath = path;
|
|
SelectedRemoteEntry = null;
|
|
|
|
RemoteEntries.Clear();
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
|
|
}
|
|
|
|
RemoteTrail.Clear();
|
|
|
|
foreach (var (name, crumb) in SftpPath.Trail(path))
|
|
{
|
|
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
|
}
|
|
|
|
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
|
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
|
/// something that returns before the click has finished being handled.
|
|
/// </remarks>
|
|
private void NavigateLocal(string path)
|
|
{
|
|
try
|
|
{
|
|
var entries = LocalDirectory.List(path);
|
|
|
|
LocalPath = Path.GetFullPath(path);
|
|
SelectedLocalEntry = null;
|
|
|
|
LocalEntries.Clear();
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
LocalEntries.Add(new LocalEntryRowViewModel(entry));
|
|
}
|
|
|
|
RebuildLocalTrail();
|
|
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
|
{
|
|
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
|
|
// pane would look like a directory that had become empty.
|
|
Status = $"{path} could not be read: {exception.Message}";
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
|
|
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
|
|
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
|
|
/// </remarks>
|
|
private void RebuildLocalTrail()
|
|
{
|
|
var crumbs = new List<CrumbViewModel>();
|
|
|
|
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
|
|
{
|
|
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
|
|
}
|
|
|
|
LocalTrail.Clear();
|
|
|
|
foreach (var crumb in crumbs)
|
|
{
|
|
LocalTrail.Add(crumb);
|
|
}
|
|
}
|
|
|
|
private void RefreshHosts()
|
|
{
|
|
Hosts.Clear();
|
|
|
|
if (vault is not { } open)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var host in open.Hosts)
|
|
{
|
|
Hosts.Add(host);
|
|
}
|
|
|
|
SelectedHost ??= Hosts.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>The session, or a failure a queue row can carry.</summary>
|
|
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;
|
|
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
|
|
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
|
|
/// instead of one for the first change and one for the rest.
|
|
/// </remarks>
|
|
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
|
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));
|
|
});
|
|
|
|
/// <remarks>
|
|
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
|
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
|
/// pane permanently disabled.
|
|
/// </remarks>
|
|
private async Task RunAsync(string busyMessage, Func<Task> work)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
Status = busyMessage;
|
|
|
|
try
|
|
{
|
|
await work().ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Status = "Cancelled.";
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
Status = exception.Message;
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
|
|
|
partial void OnIsConnectedChanged(bool value)
|
|
{
|
|
OnPropertyChanged(nameof(CanDownload));
|
|
OnPropertyChanged(nameof(CanUpload));
|
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
|
|
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
|
|
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
|
|
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
|
|
/// </remarks>
|
|
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
|
{
|
|
OnPropertyChanged(nameof(CanDownload));
|
|
|
|
PendingRemoteDeletion = null;
|
|
}
|
|
|
|
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
|
{
|
|
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
|
}
|
|
|
|
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
|
OnPropertyChanged(nameof(CanUpload));
|
|
|
|
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
|
OnPropertyChanged(nameof(HasPendingHostKey));
|
|
|
|
partial void OnHostKeyMismatchChanged(string? value) =>
|
|
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
|
}
|