Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
T
jaap-jan 810bc48d3f Tell the keep-alive wire when the Files session opens and closes
HasLiveFileSession answers the phone's foreground-service question — is
there a connection here that dying with the process would sever — and a
bucket answers no, because HTTP holds nothing open. ActivityChanged now
also fires at the end of MarkHostConnected and CloseSessionAsync, where
both facts it reads are finally true together.

Also makes the bucket pins test actually open a bucket: it never set
Remote, so CONNECT dialled the auto-selected host, and its assertions
passed only because that host had no pins either.
2026-08-09 10:14:10 +02:00

2041 lines
86 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
internal enum RemoteKind
{
/// <summary>A host, over SFTP.</summary>
Host,
/// <summary>An S3-compatible bucket.</summary>
Bucket,
}
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param>
/// <param name="Path">The absolute path that reaches it.</param>
internal sealed record CrumbViewModel(string Name, string Path);
/// <summary>One remote file or directory, as a row.</summary>
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
{
internal SftpEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsNavigable;
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
/// <remarks>
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
/// </remarks>
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
/// <remarks>
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
/// </remarks>
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
/// <remarks>
/// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket
/// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to
/// report for one and leaves the timestamp at its default. Formatting that prints
/// <c>0001-01-01 00:00</c>, which is not a quiet "unknown": it is a date, and a listing that states one
/// for every folder in a bucket is stating something untrue.
/// </remarks>
internal string Modified =>
entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
internal string Permissions => entry.Permissions;
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
internal bool IsExecutable => entry.IsExecutable;
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
internal bool IsWorldWritable => entry.IsWorldWritable;
}
/// <summary>One local file or directory, as a row.</summary>
/// <remarks>
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
/// </remarks>
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
{
internal LocalEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsDirectory;
internal bool IsFile => !entry.IsDirectory;
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}
/// <summary>How this screen writes a modification time.</summary>
/// <remarks>
/// <para>
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
/// </para>
/// <para>
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
/// </para>
/// </remarks>
internal static class Timestamps
{
internal static string Format(DateTimeOffset moment) =>
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
/// <summary>One transfer, as a row in the queue.</summary>
/// <remarks>
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
/// </remarks>
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
[ObservableProperty]
private TransferSnapshot transfer = snapshot;
internal Guid Id => Transfer.Id;
internal string Name => Transfer.Name;
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
internal string Path => Transfer.Direction is TransferDirection.Download
? Transfer.RemotePath
: Transfer.LocalPath;
/// <summary>
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
/// </summary>
/// <remarks>
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
/// instead.
/// </remarks>
internal string Label
{
get
{
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
// translation to name the file at the end of it.
var source = System.IO.Path.GetFileName(sourcePath);
return $"{source} → {destinationDirectory}";
}
}
internal double Percent => Transfer.Fraction * 100;
/// <summary>
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
/// </summary>
/// <remarks>
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
/// the bar alone cannot state exactly.
/// </remarks>
internal string StatusWord => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running => string.Create(
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
TransferState.Completed => "done",
TransferState.Cancelled => "stopped",
_ => "failed",
};
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
internal bool IsInProgress => Transfer.State is TransferState.Running;
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
internal bool IsDone => Transfer.State is TransferState.Completed;
/// <summary>
/// What the row says about where it has got to.
/// </summary>
/// <remarks>
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
/// whether it is still moving.
/// </remarks>
internal string Progress => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running =>
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
TransferState.Completed => ByteSize.Format(Transfer.Length),
TransferState.Cancelled when Transfer.Transferred > 0 =>
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
TransferState.Cancelled => "stopped",
_ => Transfer.Failure ?? "failed",
};
internal string StateLabel => Transfer.State switch
{
TransferState.Queued => "QUEUED",
TransferState.Running => "RUNNING",
TransferState.Completed => "DONE",
TransferState.Cancelled => "STOPPED",
_ => "FAILED",
};
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
internal bool IsFinished => Transfer.IsFinished;
internal bool CanResume => Transfer.CanResume;
internal bool HasFailed => Transfer.State is TransferState.Failed;
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
/// <remarks>
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
/// wording differs. See <see cref="RetryLabel"/>.
/// </remarks>
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
/// <remarks>
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
/// raising only the ones that happened to change is how a progress bar moves under a label that still
/// says "queued".
/// </remarks>
partial void OnTransferChanged(TransferSnapshot value)
{
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StatusWord));
OnPropertyChanged(nameof(IsInProgress));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsFinished));
OnPropertyChanged(nameof(CanResume));
OnPropertyChanged(nameof(CanRetry));
OnPropertyChanged(nameof(HasFailed));
OnPropertyChanged(nameof(RetryLabel));
}
}
/// <summary>
/// Something on the host that has been asked about and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
/// has no wastebasket to put them in.
/// </para>
/// <para>
/// It carries the full path rather than only the name, because the name is the half that does not identify
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
/// </para>
/// </remarks>
/// <param name="Name">What the row was called.</param>
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
{
/// <summary>The question, naming the kind because the two behave differently.</summary>
internal string Question => IsDirectory
? $"Delete the directory '{Name}' on the host?"
: $"Delete '{Name}' on the host?";
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
internal string Consequence => IsDirectory
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
+ "copy and there is no undo."
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
+ "touched, and there is no undo.";
}
/// <summary>
/// The transfers screen: a host, two directory panes, and the queue between them.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
/// The consequence a user sees is that the host records a second login, and that a host whose password is
/// typed each time asks for it again here.
/// </para>
/// <para>
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
/// host list — those are decrypted vault items — and not the connection or the queue.
/// </para>
/// </remarks>
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
{
private readonly ISftpSessionFactory sftp;
private readonly FileTransferQueue queue;
private readonly Action<Action> post;
/// <summary>
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
/// <see cref="QueueDeliveredDownload"/>.
/// </summary>
/// <remarks>
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
/// per transfer on any head that ever normalises one.
/// </remarks>
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// What to do with a completed download whose real destination this layer cannot write to.
/// </summary>
/// <remarks>
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
/// download that failed once and succeeded on the second attempt must still end up where the person
/// pointed. See <see cref="QueueDeliveredDownload"/>.
/// </remarks>
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private IRemoteFileStore? session;
private ConnectionRecorder? connectionLog;
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
private IObjectStoreFactory? objectStores;
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
/// <remarks>
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
/// row it came from may have been replaced by a background sync. The address is the one that was
/// actually dialled, which is the whole point of capturing it at connect.
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
/// <summary>
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
/// connected.
/// </summary>
/// <remarks>
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
/// the first, for no reader that could not already reach it here.
/// </remarks>
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
private bool disposed;
/// <param name="sftp">Opens SFTP sessions.</param>
/// <param name="clock">Time source, for transfer rates and timestamps.</param>
/// <param name="post">
/// Runs an action on the thread this view model's collections are read from. Defaults to the UI thread's
/// dispatcher, which is the answer in every real head.
/// <para>
/// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for the reason
/// <c>VaultViewModel</c>'s clipboard is one. <c>Dispatcher.UIThread</c> is process-wide and belongs to
/// whichever thread touched it first, so a test that posts through it is asserting on a queue owned by
/// some other test's thread — which passes or throws "the calling thread cannot access this object"
/// depending on the order a runner happened to schedule its classes in. Running the action inline
/// removes the thread from the question rather than making the test guess it right.
/// </para>
/// </param>
/// <param name="addBucket">
/// Takes the user to where a bucket is made, or null in a head that has nowhere to take them.
/// <para>
/// A delegate rather than this screen making one itself, and it is the same seam the vaults screen uses
/// for the shell: a bucket is a keychain item, the editor that writes one belongs to the keychain screen,
/// and duplicating it here would be a second form writing the same secret. What this screen owns is the
/// knowledge that somebody standing on it with no buckets needs to be sent somewhere.
/// </para>
/// </param>
internal TransfersViewModel(
ISftpSessionFactory sftp,
TimeProvider clock,
Action<Action>? post = null,
Action? addBucket = null)
{
this.sftp = sftp;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.addBucket = addBucket;
// The supplier answers with whatever session is current at the moment a transfer starts, which is
// what lets a queue survive a disconnect and reconnect without every queued row failing.
queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
// which is the life of the process.
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
Transfers.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasTransfers));
OnPropertyChanged(nameof(ActiveTransfersLabel));
};
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
// vault on every synchronisation pass, so the invitation these flags decide between has to change
// with them rather than at the moment somebody pressed something. That is the path that matters —
// adding the first bucket happens on another screen, and this one has to notice when it comes back.
Buckets.CollectionChanged += (_, _) => RaiseBucketState();
}
/// <summary>Where a bucket is made, or null in a head with nowhere to send anybody.</summary>
private readonly Action? addBucket;
/// <summary>Goes to the keychain with the bucket editor open.</summary>
/// <remarks>
/// The dead end this replaces was a real one: SELECT BUCKET opened a picker with nothing in it, and
/// nothing anywhere on the screen said that a bucket is made on the keychain screen. Somebody who had
/// come to S3 to add one had arrived at the place it is used and been shown no route to the place it is
/// created.
/// </remarks>
[RelayCommand]
private void AddBucket()
{
// Closed first, so returning here from the keychain does not find a picker still open over a list
// that has since gained the bucket it was empty of.
IsChoosingRemote = false;
addBucket?.Invoke();
}
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
/// <remarks>
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>
/// Whether there is a bucket to open at all.
/// </summary>
/// <remarks>
/// The question this screen used to answer by showing an empty combo box. A bucket is made on the
/// keychain screen and nowhere else, and S3 is where somebody goes looking for it — so a picker with
/// nothing in it and no sentence beside it was, from where the user was standing, an application with no
/// way to add a bucket. See <see cref="AddBucket"/>.
/// </remarks>
internal bool HasBuckets => Buckets.Count > 0;
/// <summary>Whether this screen is on S3 with a bucket to offer.</summary>
/// <remarks>
/// The two flags are resolved here rather than combined in markup, because a binding cannot say "and" —
/// and because the pair is one question with two answers, which is easier to keep straight in one place
/// than in four <c>IsVisible</c> expressions that have to stay each other's opposites.
/// </remarks>
internal bool ShowsBucketChoice => ShowsBucketPicker && HasBuckets;
/// <summary>Whether this screen is on S3 with nothing to open yet.</summary>
internal bool ShowsNoBuckets => ShowsBucketPicker && !HasBuckets;
/// <summary>
/// Which sort of remote the right-hand pane is about to open.
/// </summary>
/// <remarks>
/// <para>
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
/// the host editor's authentication picker does — and the reason is that these two are not
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
/// to look at than two clearly separate choices.
/// </para>
/// <para>
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
/// keychain's categories do: a selection binding moves before a command could refuse it.
/// </para>
/// </remarks>
[ObservableProperty]
private RemoteKind remote;
/// <summary>Whether the picker is showing hosts.</summary>
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
/// <summary>Whether the picker is showing buckets.</summary>
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
/// <summary>
/// Whether the picker is open, as opposed to the invitation that offers it.
/// </summary>
/// <remarks>
/// <para>
/// The desktop's connect bar is gone — a strip of controls across the top of a screen that is not
/// connected to anything, asking a question the empty right-hand pane was already asking silently. What
/// replaced it is the pane itself: an invitation where the listing would be, and this flag is the step
/// between "connect to a host" and the picker that does it.
/// </para>
/// <para>
/// Two steps rather than a picker sitting open, because the pane is the whole answer to "why is this
/// half of the screen empty" and a combo box does not say that. The phone does not use this: its screen
/// is one pane at a time, so the picker <em>is</em> what it shows before a connection exists.
/// </para>
/// <para>
/// It is cleared by everything that changes what the picker would be picking — connecting, disconnecting,
/// switching between SFTP and S3, and losing the vault — so an open form is never left over a pane that
/// has moved on. See the property-changed hooks at the foot of this file.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isChoosingRemote;
/// <summary>
/// What the button that opens the remote says.
/// </summary>
/// <remarks>
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
/// DISCONNECT step look like a bug rather than the shape of the protocol.
/// </remarks>
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
/// <remarks>
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
/// screen that quietly reused it would make a one-time password appear to work twice.
/// </remarks>
[ObservableProperty]
private string typedPassword = string.Empty;
/// <remarks>
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
/// it was the only thing on the screen saying so — the connect bar it sat in had a picker and a button
/// and no prose at all. The invitation that replaced the bar says it in a heading, a sentence and a
/// button, so a status line repeating it underneath was the same instruction three times.
/// </remarks>
[ObservableProperty]
private string status = "Nothing is open yet.";
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isConnected;
/// <summary>
/// Whether something is being dragged over the local pane, and whether it would be accepted.
/// </summary>
/// <remarks>
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
/// window that has stopped responding.
/// </remarks>
[ObservableProperty]
private bool isLocalDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropRefused;
/// <summary>The account and endpoint actually dialled, once connected.</summary>
[ObservableProperty]
private string? connectedTo;
/// <summary>
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
/// while nothing is connected, or while what is connected is a bucket rather than a host.
/// </summary>
/// <remarks>
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
/// </remarks>
[ObservableProperty]
private string? connectedCipher;
/// <summary>
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
/// service question, not the desktop's.
/// </summary>
/// <remarks>
/// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
/// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
/// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
/// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
/// desktop has no such question because nothing stops its process for having gone quiet.
/// </remarks>
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
[ObservableProperty]
private string? connectedHostKeyAlgorithm;
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did, or
/// null while nothing is connected.
/// </summary>
/// <remarks>
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
/// the one that actually authenticated.
/// </remarks>
[ObservableProperty]
private string? connectedIdentityLabel;
/// <summary>
/// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws
/// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See
/// <see cref="VaultViewModel.EditorPinnedPaths"/> for where a pin is actually added or removed; this is
/// a read of what was already saved there.
/// </summary>
/// <remarks>
/// Captured at connect, the same moment <see cref="ConnectedTo"/> is, rather than followed live off the
/// host row's own <c>PinnedPaths</c>. A pin edited while this session stays open shows up on the next
/// connect rather than mid-session — the same lag <see cref="ConnectedTo"/> itself already carries for
/// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying
/// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all:
/// <see cref="OpenBucketAsync"/> leaves this empty rather than reading as "not yet known", which is what
/// empty already means for a host that connected with none pinned.
/// </remarks>
internal ObservableCollection<string> ConnectedPinnedPaths { get; } = [];
/// <summary>Whether the connected host or bucket has any pins to draw as chips.</summary>
/// <remarks>
/// A read of <see cref="ConnectedPinnedPaths"/> rather than an <c>[ObservableProperty]</c> of its own,
/// so it is raised by hand at each of the three places that collection is repopulated or cleared —
/// <see cref="MarkHostConnected"/>, <see cref="OpenBucketAsync"/> and <see cref="CloseSessionAsync"/>.
/// </remarks>
internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
/// <remarks>
/// The resolved binding, for the reason its counterpart on <c>VaultViewModel</c> gives: a host naming
/// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen
/// has its own password box and so needs its own copy of the question — but it must give the same
/// answer, or one screen would ask for a password the other knew was not wanted.
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
ShowsHostPicker
&& (SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
// ---- The remote pane ----
[ObservableProperty]
private string remotePath = string.Empty;
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
[ObservableProperty]
private RemoteEntryRowViewModel? selectedRemoteEntry;
/// <summary>What a new directory would be called, when the user is making one.</summary>
[ObservableProperty]
private string newRemoteFolder = string.Empty;
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
[ObservableProperty]
private RemoteDeletionRequest? pendingRemoteDeletion;
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
/// <summary>Whether the pane's DELETE is live.</summary>
/// <remarks>
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
/// move UP and REFRESH out from under the pointer.
/// </remarks>
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
// ---- The local pane ----
[ObservableProperty]
private string localPath = LocalDirectory.Home;
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
/// <summary>
/// The drives this machine has, as somewhere the local pane can jump to.
/// </summary>
/// <remarks>
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
/// be unreachable from an application whose whole purpose on this screen is to move one.
/// </remarks>
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
[ObservableProperty]
private LocalEntryRowViewModel? selectedLocalEntry;
internal bool HasLocalEntries => LocalEntries.Count > 0;
// ---- The queue ----
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
/// <summary>
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
/// disconnects.
/// </summary>
/// <remarks>
/// For a head that has to tell the operating system what this process is doing — Android's foreground
/// service, which must be up for as long as bytes are moving, or a host session sits open, and down
/// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
/// collection announces rows arriving and leaving, and the transition that matters most is neither of
/// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
/// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
/// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
/// </remarks>
internal event EventHandler? ActivityChanged;
/// <summary>How many transfers are moving or waiting to move.</summary>
/// <remarks>
/// Queued counts as active. A queue with three files in it and one of them running is a process that
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
/// </remarks>
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
/// <summary>
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
/// </summary>
/// <remarks>
/// A string of its own rather than a converter in the markup, for the reason every other label in this
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
/// that shape rather than a chip claiming activity that has already finished.
/// </remarks>
internal string ActiveTransfersLabel => string.Create(
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
/// <param name="openVault">The open keychain.</param>
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
/// <param name="log">
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
/// when the keychain locks still ends somewhere.
/// </param>
internal void Attach(
VaultViewModel openVault,
VaultKnownHostStore hostKeys,
ConnectionRecorder? log = null,
IObjectStoreFactory? buckets = null)
{
vault = openVault;
knownHosts = hostKeys;
connectionLog = log;
objectStores = buckets;
// Followed rather than copied once, which is the difference between a picker that is right at unlock
// and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
// synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
// snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
// Detach: these collections belong to a vault that is about to be disposed.
openVault.Hosts.CollectionChanged += OnVaultListChanged;
openVault.ObjectStores.CollectionChanged += OnVaultListChanged;
RefreshHosts();
// Read once per unlock rather than per navigation: a drive appearing while the application is open
// is possible and rare, and probing every removable drive on every click into a folder is not.
LocalRoots.Clear();
foreach (var root in LocalDirectory.Roots())
{
LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
}
RefreshLocalCommand.Execute(null);
}
/// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
/// <remarks>
/// <para>
/// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
/// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
/// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
/// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
/// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
/// buttons past the window's edge at the session shell's 472-pixel budget.
/// </para>
/// <para>
/// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
/// application has ever typed into already means "my home directory" by it, which is exactly what the
/// chip does when pressed.
/// </para>
/// </remarks>
internal static string RootChipName(string root)
{
if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
{
return "~";
}
var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
if (trimmed.Length == 0)
{
// Unix's "/": trimming eats the whole string, and the root's name is the root itself.
return "/";
}
// A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
// no file-name segment at all, and the trimmed root is already the two-character name it always had.
var name = Path.GetFileName(trimmed);
return name.Length == 0 ? trimmed : name;
}
/// <summary>
/// Gives up the vault, keeping the connection and anything in flight.
/// </summary>
/// <remarks>
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
/// </remarks>
internal void Detach()
{
if (vault is { } open)
{
open.Hosts.CollectionChanged -= OnVaultListChanged;
open.ObjectStores.CollectionChanged -= OnVaultListChanged;
}
vault = null;
knownHosts = null;
Hosts.Clear();
Buckets.Clear();
SelectedHost = null;
SelectedBucket = null;
TypedPassword = string.Empty;
// The picker with it. Its two lists have just been emptied, so leaving it open would show a form
// offering a choice between nothing.
IsChoosingRemote = false;
}
// ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this
// screen offers is a destination now rather than a control on the screen — both heads reach it through
// MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first.
// Keeping the command would have left one nothing could invoke.
/// <summary>Opens the picker, from the invitation in the empty remote pane.</summary>
[RelayCommand]
private void BeginChoosingRemote() => IsChoosingRemote = true;
/// <summary>
/// Puts the picker away without connecting.
/// </summary>
/// <remarks>
/// The typed password goes with it. It is a secret nobody asked to keep, and leaving it in the box would
/// mean the next person to open the picker — for a different host, possibly — starts with somebody
/// else's password already typed in.
/// </remarks>
[RelayCommand]
private void CancelChoosingRemote()
{
IsChoosingRemote = false;
TypedPassword = string.Empty;
}
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
Remote is RemoteKind.Bucket
? OpenBucketAsync(cancellationToken)
: ConnectToHostAsync(cancellationToken);
/// <summary>
/// Opens the chosen bucket.
/// </summary>
/// <remarks>
/// <para>
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
/// why the failure this reports is a listing failure rather than a connection one — there is no
/// connection to fail.
/// </para>
/// <para>
/// It goes through the same session field, the same queue and the same panes as a host, because by this
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
/// SFTP and needed no change.
/// </para>
/// </remarks>
private async Task OpenBucketAsync(CancellationToken cancellationToken)
{
if (objectStores is not { } factory)
{
Status = "This build cannot open buckets.";
return;
}
if (SelectedBucket is not { } row)
{
Status = "Choose a bucket first.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Opening {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
session = factory.Open(row.Store);
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
// No SSH underneath a bucket, so none of the three has an honest value — see
// ConnectedCipher's own remark.
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
// And no pins either — see ConnectedPinnedPaths's own remark.
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Opened {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
{
if (vault is not { } open || SelectedHost is not { } row)
{
Status = "Choose a host first.";
return;
}
if (!open.TryBuildConnectionRequest(
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
{
Status = refusal;
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
ISftpSession opened;
try
{
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
}
catch (SshHostKeyUnknownException exception)
{
// First contact, decided here rather than inherited from a terminal. File transfer is
// its own connection, so it makes its own trust decision — and the pin it writes is the
// same pin a shell would then find.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
return;
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. Nothing was connected.";
return;
}
session = opened;
TypedPassword = string.Empty;
MarkHostConnected(opened, request, row, identityLabel);
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
/// disconnect log closes out later.
/// </summary>
/// <remarks>
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
/// as <c>IRemoteFileStore</c> and does not carry either.
/// </remarks>
private void MarkHostConnected(
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
{
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
ConnectedCipher = opened.Cipher;
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
ConnectedIdentityLabel = identityLabel;
// See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow.
ConnectedPinnedPaths.Clear();
foreach (var path in row.Host.PinnedPaths)
{
ConnectedPinnedPaths.Add(path);
}
OnPropertyChanged(nameof(HasConnectedPins));
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
// the host's — and anybody comparing the two would be right to believe the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
// Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
// and ConnectedCipher second — a partial method firing off the first assignment would read
// HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
// there. Only at the end of this method are both facts actually true together.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Closes the file-transfer session.
/// </summary>
/// <remarks>
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
/// accident.
/// </remarks>
[RelayCommand]
private async Task DisconnectAsync()
{
if (queue.IsBusy)
{
Status = "There are transfers still running. Stop them first, or let them finish.";
return;
}
await CloseSessionAsync().ConfigureAwait(true);
Status = "Disconnected. Anything already transferred is where it landed.";
}
/// <summary>Pins the offered host key and connects.</summary>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation || knownHosts is null)
{
return;
}
try
{
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
return;
}
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
/// </summary>
/// <remarks>
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
/// with two clicks instead of one.
/// </remarks>
[RelayCommand]
private void RejectHostKey()
{
var wasOffered = PendingHostKey is not null;
PendingHostKey = null;
HostKeyMismatch = null;
Status = wasOffered
? "The host key was not trusted, so nothing was connected."
: "Nothing was connected.";
}
// ---- Navigation ----
/// <summary>Goes to a remote directory.</summary>
[RelayCommand]
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
{
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
}
/// <summary>Goes up one remote directory.</summary>
[RelayCommand]
private async Task RemoteUpAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
}
}
/// <summary>Re-reads the remote directory.</summary>
[RelayCommand]
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
/// </summary>
/// <remarks>
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
/// directory reports as a link and resolving every one of them would be a round trip per row. The
/// failure, when it is a link to a file, is the server's own and says so.
/// </remarks>
[RelayCommand]
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
{
if (SelectedRemoteEntry is { IsNavigable: true } row)
{
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>Goes to a local directory.</summary>
[RelayCommand]
private void GoLocal(string path) => NavigateLocal(path);
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
/// <remarks>
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
/// </remarks>
[RelayCommand]
private void LocalUp()
{
if (LocalDirectory.Parent(LocalPath) is { } parent)
{
NavigateLocal(parent);
return;
}
Status = "That is the top of this drive. Use the drive list to go to another one.";
}
/// <summary>Re-reads the local directory.</summary>
[RelayCommand]
private void RefreshLocal() => NavigateLocal(LocalPath);
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
[RelayCommand]
private void OpenLocal()
{
if (SelectedLocalEntry is { IsNavigable: true } row)
{
NavigateLocal(row.FullPath);
}
}
// ---- Moving files ----
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
[RelayCommand]
private void Download()
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose a file on the host to download.";
return;
}
QueueDownloads([row]);
}
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
[RelayCommand]
private void Upload()
{
if (SelectedLocalEntry is not { } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
QueueUploads([row.FullPath]);
}
/// <summary>
/// Queues every one of these local paths for upload into the remote directory showing.
/// </summary>
/// <remarks>
/// <para>
/// The one path both the button and a drop go through, so there is one set of rules about what can be
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
/// were dragged, from this window's own pane or from the file manager.
/// </para>
/// <para>
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
/// </para>
/// <para>
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
/// about all five.
/// </para>
/// </remarks>
internal void QueueUploads(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
var missing = 0;
foreach (var path in paths)
{
if (Directory.Exists(path))
{
directories++;
continue;
}
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
// paths in an OS drop come from another process, which is not obliged to be right about them.
if (!File.Exists(path))
{
missing++;
continue;
}
var length = new FileInfo(path).Length;
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
queue.Enqueue(TransferDirection.Upload, path, destination, length);
queued++;
}
Status = Describe(queued, "upload into", RemotePath, directories, missing);
}
/// <summary>
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
/// the transfer no longer needs them.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
/// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
/// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
/// last attempt reached. So the head copies the document into the application's own cache first and
/// hands over the copy, which is a real file that behaves like every other thing in this queue.
/// </para>
/// <para>
/// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
/// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
/// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
/// next launch by the head that made it, which is the only place that knows where it put it.
/// </para>
/// </remarks>
internal void QueueStagedUploads(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
foreach (var path in paths)
{
staged.Add(path);
}
QueueUploads(paths);
}
/// <summary>
/// Queues one download into a local file this application made, and hands the finished bytes to
/// something that knows where they were really meant to go.
/// </summary>
/// <remarks>
/// <para>
/// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
/// no directory a download could simply be written into: what the person chose is a document handed back
/// by the system's save picker, which this layer cannot open and the queue could not resume against. So
/// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
/// that raised the picker — copies the result out once there is a result to copy.
/// </para>
/// <para>
/// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
/// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
/// arrive while the application is in the background, where Android will not show it at all. The cost is
/// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
/// download that then fails leaves an empty file where it was pointed.
/// </para>
/// <para>
/// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
/// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
/// thrown away at the one moment it is worth the most.
/// </para>
/// </remarks>
/// <param name="row">The remote file to fetch.</param>
/// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
/// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
internal void QueueDeliveredDownload(
RemoteEntryRowViewModel row,
string localPath,
Func<string, Task> deliver)
{
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(deliver);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
if (!row.IsFile)
{
Status = "Only files can be transferred.";
return;
}
staged.Add(localPath);
deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
deliver;
Status = $"Queued {row.Name} for download.";
}
/// <remarks>
/// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
/// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
/// status line is the only report either way, which is the same place every other outcome on this screen
/// is reported.
/// </remarks>
private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
{
var name = Path.GetFileName(localPath);
try
{
await deliver(localPath).ConfigureAwait(true);
Status = $"Saved {name}.";
ReleaseStaged(localPath);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
}
}
/// <remarks>
/// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
/// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
/// in it is not something this method is entitled to reason about. Failures are ignored rather than
/// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
/// a person could do with the news.
/// </remarks>
private void ReleaseStaged(string localPath)
{
if (!staged.Remove(localPath))
{
return;
}
try
{
File.Delete(localPath);
if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
{
Directory.Delete(folder);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
}
}
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
/// <inheritdoc cref="QueueUploads" path="/remarks" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
{
ArgumentNullException.ThrowIfNull(rows);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
foreach (var row in rows)
{
if (!row.IsFile)
{
directories++;
continue;
}
queue.Enqueue(
TransferDirection.Download,
Path.Combine(LocalPath, row.Name),
row.FullPath,
row.Entry.Length);
queued++;
}
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
}
/// <remarks>
/// One sentence for both directions and every shape of partial success. What it must never do is stay
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
/// looking for the other two in a queue they are not in.
/// </remarks>
private static string Describe(int queued, string verb, string destination, int directories, int missing)
{
var files = queued == 1 ? "1 file" : $"{queued} files";
var said = queued == 0
? "Nothing was queued."
: $"Queued {files} for {verb} {destination}.";
if (directories > 0)
{
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
said += $" {folders} skipped — only files can be transferred.";
}
if (missing > 0)
{
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
said += $" {gone} no longer there.";
}
return said;
}
/// <summary>Stops one transfer.</summary>
[RelayCommand]
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
/// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
[RelayCommand]
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
/// <summary>Removes one stopped transfer and whatever it left behind.</summary>
[RelayCommand]
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
{
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
// removed anyway would take the only view of a transfer that was still running.
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
{
Transfers.Remove(row);
// Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
// RESUME the staged copy was being kept for, and any delivery that was waiting on it.
deliveries.Remove(row.Id);
ReleaseStaged(row.Transfer.LocalPath);
}
}
/// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
[RelayCommand]
private void ClearCompleted()
{
queue.ClearCompleted();
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
{
Transfers.Remove(row);
}
}
// ---- Changing the remote directory ----
/// <summary>Creates a directory on the host.</summary>
[RelayCommand]
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
{
var name = NewRemoteFolder.Trim();
if (name.Length == 0)
{
Status = "Type a name for the new directory.";
return;
}
if (name.Contains('/', StringComparison.Ordinal))
{
// One directory, whose parent must exist. A name with a separator in it would be a request to
// create a path, and this button creates a directory in the one on screen.
Status = "A directory name cannot contain '/'. Make one level at a time.";
return;
}
await RunAsync(
$"Creating {name}…",
async () =>
{
await RequireSession()
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
.ConfigureAwait(true);
NewRemoteFolder = string.Empty;
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"Created {name}.";
}).ConfigureAwait(true);
}
/// <summary>
/// Asks whether the chosen remote file, or empty directory, should go.
/// </summary>
/// <remarks>
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
/// what it is — the only thing this application destroys that neither the server nor this machine has a
/// copy of.
/// </remarks>
[RelayCommand]
private void DeleteRemote()
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose something on the host to delete.";
return;
}
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
}
/// <summary>
/// Deletes what was agreed to.
/// </summary>
/// <remarks>
/// Not recursive, and the refusal comes from the server rather than from a check here — see
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
/// which is what makes the question a promise: nothing between asking and answering can point it
/// somewhere else.
/// </remarks>
[RelayCommand]
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
{
if (PendingRemoteDeletion is not { } request)
{
return;
}
PendingRemoteDeletion = null;
await RunAsync(
$"Deleting {request.Name}…",
async () =>
{
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"Deleted {request.Name}.";
}).ConfigureAwait(true);
}
/// <summary>Thinks better of it.</summary>
[RelayCommand]
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
queue.Changed -= OnTransferChanged;
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
// unwinding before the transport under it goes.
await queue.DisposeAsync().ConfigureAwait(false);
if (session is not null)
{
await session.DisposeAsync().ConfigureAwait(false);
session = null;
}
}
/// <summary>
/// Goes to a remote directory, on its own.
/// </summary>
/// <remarks>
/// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
/// something else — connecting, making a directory, deleting one — can list without going back through
/// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
/// anywhere to explain it.
/// </remarks>
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
{
if (session is null)
{
Status = "Connect to a host first.";
return Task.CompletedTask;
}
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
}
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
{
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
RemotePath = path;
SelectedRemoteEntry = null;
RemoteEntries.Clear();
foreach (var entry in entries)
{
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
}
RemoteTrail.Clear();
foreach (var (name, crumb) in SftpPath.Trail(path))
{
RemoteTrail.Add(new CrumbViewModel(name, crumb));
}
Status = string.Empty;
}
/// <remarks>
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
/// something that returns before the click has finished being handled.
/// </remarks>
private void NavigateLocal(string path)
{
try
{
var entries = LocalDirectory.List(path);
LocalPath = Path.GetFullPath(path);
SelectedLocalEntry = null;
LocalEntries.Clear();
foreach (var entry in entries)
{
LocalEntries.Add(new LocalEntryRowViewModel(entry));
}
RebuildLocalTrail();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
// pane would look like a directory that had become empty.
Status = $"{path} could not be read: {exception.Message}";
}
}
/// <remarks>
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
/// </remarks>
private void RebuildLocalTrail()
{
var crumbs = new List<CrumbViewModel>();
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
{
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
}
LocalTrail.Clear();
foreach (var crumb in crumbs)
{
LocalTrail.Add(crumb);
}
}
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
/// <summary>
/// Rebuilds both pickers from the vault's lists.
/// </summary>
/// <remarks>
/// <para>
/// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts
/// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first.
/// </para>
/// <para>
/// <b>The selection is re-found by id rather than kept.</b> The vault replaces every row on every reload,
/// which a synchronisation pass does once a minute, so holding the object would leave the picker showing
/// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also
/// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be
/// connected to.
/// </para>
/// </remarks>
private void RefreshHosts()
{
var host = SelectedHost?.EntityId;
var bucket = SelectedBucket?.EntityId;
Hosts.Clear();
if (vault is not { } open)
{
return;
}
foreach (var row in open.Hosts)
{
Hosts.Add(row);
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
private IRemoteFileStore RequireSession() =>
session ?? throw new InvalidOperationException(
"This screen is not connected to a host, so there is nowhere to move the file.");
private async Task CloseSessionAsync()
{
if (session is { } open)
{
session = null;
await open.DisposeAsync().ConfigureAwait(true);
}
// Written whole here rather than through an open/close ticket, because this connection is not one
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
// with a real terminal's.
if (connected is { } record)
{
connected = null;
connectionLog?.Record(
record.Address,
record.HostLabel,
record.HostId,
ConnectionKind.Sftp,
record.StartedAt,
TimeProvider.System.GetUtcNow(),
ConnectionOutcome.Closed);
}
IsConnected = false;
ConnectedTo = null;
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
RemotePath = string.Empty;
RemoteEntries.Clear();
RemoteTrail.Clear();
SelectedRemoteEntry = null;
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <remarks>
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
/// instead of one for the first change and one for the rest.
/// </remarks>
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
post(() =>
{
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
// button that will read this file again.
if (e.Transfer.State is TransferState.Completed)
{
// A staged download is not finished when the queue says so — it is finished when the bytes
// reach the document the person picked, and only the head can put them there. So the copy
// is released by the delivery rather than here, or it would be deleted on the way.
if (deliveries.Remove(e.Transfer.Id, out var deliver))
{
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
}
else
{
ReleaseStaged(e.Transfer.LocalPath);
}
}
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
{
existing.Transfer = e.Transfer;
}
else
{
Transfers.Add(new TransferRowViewModel(e.Transfer));
}
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
// rows count as active without the collection itself gaining or losing a row, so the strip's own
// count chip needs its own raise here.
OnPropertyChanged(nameof(ActiveTransfersLabel));
ActivityChanged?.Invoke(this, EventArgs.Empty);
});
/// <remarks>
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled.
/// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
/// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
/// picker that has nothing to do with passwords.
/// </remarks>
partial void OnRemoteChanged(RemoteKind value)
{
OnPropertyChanged(nameof(ShowsHostPicker));
OnPropertyChanged(nameof(ConnectLabel));
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
RaiseBucketState();
// Arriving at the other destination shows its invitation rather than a picker somebody left open on
// this one — and the picker it would have left open is the wrong one, since the two kinds have
// different lists behind them.
IsChoosingRemote = false;
}
/// <summary>Raises the three flags that answer "is there a bucket to open".</summary>
/// <remarks>
/// Together, because they are one fact read three ways and a caller that raised two of them would leave
/// an invitation and an empty state on screen at once.
/// </remarks>
private void RaiseBucketState()
{
OnPropertyChanged(nameof(ShowsBucketPicker));
OnPropertyChanged(nameof(HasBuckets));
OnPropertyChanged(nameof(ShowsBucketChoice));
OnPropertyChanged(nameof(ShowsNoBuckets));
}
partial void OnIsConnectedChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanUpload));
OnPropertyChanged(nameof(CanDeleteRemote));
// Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
// back to its invitation rather than to the form, so what the pane shows after a disconnect is the
// same thing it showed before anything was ever connected.
IsChoosingRemote = false;
}
/// <remarks>
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
/// </remarks>
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
{
OnPropertyChanged(nameof(CanDownload));
PendingRemoteDeletion = null;
}
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
{
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
OnPropertyChanged(nameof(CanDeleteRemote));
}
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
OnPropertyChanged(nameof(CanUpload));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}