Public Access
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.
**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.
So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.
The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.
**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.
The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.
The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.
**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.
The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.
Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.
The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.
Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.
Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
1767 lines
71 KiB
C#
1767 lines
71 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;
|
|
|
|
/// <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;
|
|
|
|
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 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;
|
|
|
|
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));
|
|
|
|
// 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;
|
|
|
|
[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.</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 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.
|
|
/// </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);
|
|
|
|
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(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()
|
|
{
|
|
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}");
|
|
|
|
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 refusal))
|
|
{
|
|
Status = refusal;
|
|
return;
|
|
}
|
|
|
|
PendingHostKey = null;
|
|
HostKeyMismatch = null;
|
|
|
|
await RunAsync(
|
|
$"Connecting to {row.Label}…",
|
|
async () =>
|
|
{
|
|
await CloseSessionAsync().ConfigureAwait(true);
|
|
|
|
try
|
|
{
|
|
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (SshHostKeyUnknownException exception)
|
|
{
|
|
// First contact, decided here rather than inherited from a terminal. File transfer is
|
|
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
|
// same pin a shell would then find.
|
|
PendingHostKey = exception.Presentation;
|
|
Status = "This host has not been seen before.";
|
|
return;
|
|
}
|
|
catch (SshHostKeyMismatchException exception)
|
|
{
|
|
HostKeyMismatch = exception.Message;
|
|
Status = "The host key has changed. Nothing was connected.";
|
|
return;
|
|
}
|
|
|
|
TypedPassword = string.Empty;
|
|
IsConnected = true;
|
|
ConnectedTo = string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{request.Username}@{request.Host}:{request.Port}");
|
|
|
|
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
|
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
|
// would disagree with the host's — and anybody comparing the two would be right to believe
|
|
// the host.
|
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
|
|
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = $"Connected to {row.Label}.";
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <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;
|
|
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) =>
|
|
post(() =>
|
|
{
|
|
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
|
|
// button that will read this file again.
|
|
if (e.Transfer.State is TransferState.Completed)
|
|
{
|
|
// A staged download is not finished when the queue says so — it is finished when the bytes
|
|
// reach the document the person picked, and only the head can put them there. So the copy
|
|
// is released by the delivery rather than here, or it would be deleted on the way.
|
|
if (deliveries.Remove(e.Transfer.Id, out var deliver))
|
|
{
|
|
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
|
|
}
|
|
else
|
|
{
|
|
ReleaseStaged(e.Transfer.LocalPath);
|
|
}
|
|
}
|
|
|
|
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
|
{
|
|
existing.Transfer = e.Transfer;
|
|
}
|
|
else
|
|
{
|
|
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
|
}
|
|
|
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
|
});
|
|
|
|
/// <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));
|
|
}
|