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;
/// What sort of remote the file browser's right-hand pane is showing.
internal enum RemoteKind
{
/// A host, over SFTP.
Host,
/// An S3-compatible bucket.
Bucket,
}
/// One segment of a path, as a button in a breadcrumb trail.
/// What the segment is called.
/// The absolute path that reaches it.
internal sealed record CrumbViewModel(string Name, string Path);
/// One remote file or directory, as a row.
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
{
internal SftpEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsNavigable;
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// The row's own Material Icons glyph — folder for a directory, a document for a file.
///
/// 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.
///
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
///
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
///
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
///
/// 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
/// 0001-01-01 00:00, 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.
///
internal string Modified =>
entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);
/// The mode as drwxr-xr-x, which is the design's PERMS column.
internal string Permissions => entry.Permissions;
/// Whether the row is a file with an execute bit, which the NAME column colours for.
internal bool IsExecutable => entry.IsExecutable;
/// Whether the row is a file anyone may write to, which the PERMS column colours for.
internal bool IsWorldWritable => entry.IsWorldWritable;
}
/// One local file or directory, as a row.
///
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking -rw-r--r--.
///
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
{
internal LocalEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsDirectory;
internal bool IsFile => !entry.IsDirectory;
///
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}
/// How this screen writes a modification time.
///
///
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
///
///
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// InvariantGlobalization, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
///
///
internal static class Timestamps
{
internal static string Format(DateTimeOffset moment) =>
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
/// One transfer, as a row in the queue.
///
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
///
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
[ObservableProperty]
private TransferSnapshot transfer = snapshot;
internal Guid Id => Transfer.Id;
internal string Name => Transfer.Name;
/// Which way, as an arrow the eye can scan a column of.
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
/// The end that is not this machine, which is the one worth showing.
internal string Path => Transfer.Direction is TransferDirection.Download
? Transfer.RemotePath
: Transfer.LocalPath;
///
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
///
///
/// 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 — for an upload, and
/// 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
/// '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 TextTrimming="CharacterEllipsis" carry the length
/// instead.
///
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;
///
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
///
///
/// Not , 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.
///
internal string StatusWord => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running => string.Create(
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
TransferState.Completed => "done",
TransferState.Cancelled => "stopped",
_ => "failed",
};
/// Whether the status word should read as the strip's own "in motion" colour.
internal bool IsInProgress => Transfer.State is TransferState.Running;
/// Whether the status word should read as the strip's own "finished, kept" colour.
internal bool IsDone => Transfer.State is TransferState.Completed;
///
/// What the row says about where it has got to.
///
///
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
/// whether it is still moving.
///
internal string Progress => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running =>
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
TransferState.Completed => ByteSize.Format(Transfer.Length),
TransferState.Cancelled when Transfer.Transferred > 0 =>
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
TransferState.Cancelled => "stopped",
_ => Transfer.Failure ?? "failed",
};
internal string StateLabel => Transfer.State switch
{
TransferState.Queued => "QUEUED",
TransferState.Running => "RUNNING",
TransferState.Completed => "DONE",
TransferState.Cancelled => "STOPPED",
_ => "FAILED",
};
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
internal bool IsFinished => Transfer.IsFinished;
internal bool CanResume => Transfer.CanResume;
internal bool HasFailed => Transfer.State is TransferState.Failed;
/// Whether a stopped transfer is worth offering to run again at all.
///
/// Wider than : a transfer that failed before it moved a byte — the host was
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
/// wording differs. See .
///
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
///
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
/// raising only the ones that happened to change is how a progress bar moves under a label that still
/// says "queued".
///
partial void OnTransferChanged(TransferSnapshot value)
{
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(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));
}
}
///
/// Something on the host that has been asked about and not yet agreed to.
///
///
///
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
/// has no wastebasket to put them in.
///
///
/// It carries the full path rather than only the name, because the name is the half that does not identify
/// anything: config in the directory that was showing a moment ago and config in the one
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
///
///
/// What the row was called.
/// Where it is, which is what the question actually promises to delete.
/// Whether it is a directory, which the host treats differently.
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
{
/// The question, naming the kind because the two behave differently.
internal string Question => IsDirectory
? $"Delete the directory '{Name}' on the host?"
: $"Delete '{Name}' on the host?";
/// What it costs, which is everything: there is no copy here and no undo there.
internal string Consequence => IsDirectory
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
+ "copy and there is no undo."
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
+ "touched, and there is no undo.";
}
///
/// The transfers screen: a host, two directory panes, and the queue between them.
///
///
///
/// Its connection is its own. SSH.NET cannot open an SFTP subsystem on a transport that is already
/// carrying a shell — see ISftpSession — so this screen authenticates separately, and connecting here
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
/// The consequence a user sees is that the host records a second login, and that a host whose password is
/// typed each time asks for it again here.
///
///
/// It outlives a lock, as terminals do. This object is created once and the vault is attached to it
/// on unlock and detached on lock, the same arrangement VaultKnownHostStore has and for the same
/// reason: MainWindowViewModel.LockAsync argues that locking must not destroy work in flight, and a
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
/// host list — those are decrypted vault items — and not the connection or the queue.
///
///
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
{
private readonly ISftpSessionFactory sftp;
private readonly FileTransferQueue queue;
private readonly Action post;
///
/// Local files that exist only so this queue could move them — see and
/// .
///
///
/// 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.
///
private readonly HashSet staged = new(StringComparer.OrdinalIgnoreCase);
///
/// What to do with a completed download whose real destination this layer cannot write to.
///
///
/// 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 .
///
private readonly Dictionary> deliveries = [];
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private IRemoteFileStore? session;
private ConnectionRecorder? connectionLog;
/// How a bucket is opened, or null in a build that was not given one.
private IObjectStoreFactory? objectStores;
/// The open SFTP connection, as the log will record it, or null when there is none.
///
/// 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.
///
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
///
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
/// connected.
///
///
/// A read of 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 . 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.
///
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
private bool disposed;
/// Opens SFTP sessions.
/// Time source, for transfer rates and timestamps.
///
/// 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.
///
/// A delegate rather than Dispatcher.UIThread reached directly, for the reason
/// VaultViewModel's clipboard is one. Dispatcher.UIThread 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.
///
///
///
/// Takes the user to where a bucket is made, or null in a head that has nowhere to take them.
///
/// 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.
///
///
internal TransfersViewModel(
ISftpSessionFactory sftp,
TimeProvider clock,
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(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();
}
/// Where a bucket is made, or null in a head with nowhere to send anybody.
private readonly Action? addBucket;
/// Goes to the keychain with the bucket editor open.
///
/// 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.
///
[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();
}
/// The hosts that can be connected to, which is the vault's list.
///
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
///
internal ObservableCollection Hosts { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// The buckets that can be browsed, which is the vault's list.
///
internal ObservableCollection Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
///
/// Whether there is a bucket to open at all.
///
///
/// 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 .
///
internal bool HasBuckets => Buckets.Count > 0;
/// Whether this screen is on S3 with a bucket to offer.
///
/// 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 IsVisible expressions that have to stay each other's opposites.
///
internal bool ShowsBucketChoice => ShowsBucketPicker && HasBuckets;
/// Whether this screen is on S3 with nothing to open yet.
internal bool ShowsNoBuckets => ShowsBucketPicker && !HasBuckets;
///
/// Which sort of remote the right-hand pane is about to open.
///
///
///
/// 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.
///
///
/// 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.
///
///
[ObservableProperty]
private RemoteKind remote;
/// Whether the picker is showing hosts.
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
/// Whether the picker is showing buckets.
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
///
/// Whether the picker is open, as opposed to the invitation that offers it.
///
///
///
/// 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.
///
///
/// 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 is what it shows before a connection exists.
///
///
/// 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.
///
///
[ObservableProperty]
private bool isChoosingRemote;
///
/// What the button that opens the remote says.
///
///
/// "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.
///
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
///
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
/// screen that quietly reused it would make a one-time password appear to work twice.
///
[ObservableProperty]
private string typedPassword = string.Empty;
///
/// 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.
///
[ObservableProperty]
private string status = "Nothing is open yet.";
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isConnected;
///
/// Whether something is being dragged over the local pane, and whether it would be accepted.
///
///
/// 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.
///
[ObservableProperty]
private bool isLocalDropTarget;
///
[ObservableProperty]
private bool isRemoteDropTarget;
///
[ObservableProperty]
private bool isRemoteDropRefused;
/// The account and endpoint actually dialled, once connected.
[ObservableProperty]
private string? connectedTo;
///
/// 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.
///
///
/// A bucket is IRemoteFileStore with no SSH underneath it at all, so it has no cipher, no host key
/// and no identity to name — 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 ISftpSession at connect time, the same moment is
/// set, because itself is typed as IRemoteFileStore and does not carry it.
///
[ObservableProperty]
private string? connectedCipher;
///
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
/// service question, not the desktop's.
///
///
/// 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 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.
///
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
/// The accepted host key's algorithm, e.g. ssh-ed25519. See .
[ObservableProperty]
private string? connectedHostKeyAlgorithm;
///
/// The display name of the key or credential that authenticated, or null when a typed password did, or
/// null while nothing is connected.
///
///
/// Threaded from '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.
///
[ObservableProperty]
private string? connectedIdentityLabel;
///
/// 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
/// for where a pin is actually added or removed; this is
/// a read of what was already saved there.
///
///
/// Captured at connect, the same moment is, rather than followed live off the
/// host row's own PinnedPaths. A pin edited while this session stays open shows up on the next
/// connect rather than mid-session — the same lag 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:
/// leaves this empty rather than reading as "not yet known", which is what
/// empty already means for a host that connected with none pinned.
///
internal ObservableCollection ConnectedPinnedPaths { get; } = [];
/// Whether the connected host or bucket has any pins to draw as chips.
///
/// A read of rather than an [ObservableProperty] of its own,
/// so it is raised by hand at each of the three places that collection is repopulated or cleared —
/// , and .
///
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;
/// Whether the chosen host will want something typed into the password box.
///
/// The resolved binding, for the reason its counterpart on VaultViewModel 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.
///
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 RemoteEntries { get; } = [];
internal ObservableCollection RemoteTrail { get; } = [];
[ObservableProperty]
private RemoteEntryRowViewModel? selectedRemoteEntry;
/// What a new directory would be called, when the user is making one.
[ObservableProperty]
private string newRemoteFolder = string.Empty;
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
/// The deletion on the host that has been asked about, or null when none has.
[ObservableProperty]
private RemoteDeletionRequest? pendingRemoteDeletion;
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
/// Whether the pane's DELETE is live.
///
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
/// move UP and REFRESH out from under the pointer.
///
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
// ---- The local pane ----
[ObservableProperty]
private string localPath = LocalDirectory.Home;
internal ObservableCollection LocalEntries { get; } = [];
internal ObservableCollection LocalTrail { get; } = [];
///
/// The drives this machine has, as somewhere the local pane can jump to.
///
///
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
/// does not: above C:\ is a list of drives rather than a directory, so without this the pane
/// could be walked to the top of the drive it opened on and no further — and a file on D: would
/// be unreachable from an application whose whole purpose on this screen is to move one.
///
internal ObservableCollection LocalRoots { get; } = [];
[ObservableProperty]
private LocalEntryRowViewModel? selectedLocalEntry;
internal bool HasLocalEntries => LocalEntries.Count > 0;
// ---- The queue ----
internal ObservableCollection Transfers { get; } = [];
///
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
/// disconnects.
///
///
/// 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 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 — and neither touches
/// at all, so they need this same announcement made by hand.
///
internal event EventHandler? ActivityChanged;
/// How many transfers are moving or waiting to move.
///
/// 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.
///
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
///
/// The v5b TRANSFERS strip's own count chip: "N active", off .
///
///
/// 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 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.
///
internal string ActiveTransfersLabel => string.Create(
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
internal bool HasTransfers => Transfers.Count > 0;
/// Whether a download of the chosen remote file would have somewhere to go.
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
/// Whether an upload of the chosen local file would have somewhere to go.
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
/// Takes an unlocked vault, so the host list has something in it.
/// The open keychain.
/// The pins this screen's own trust decisions are written to.
///
/// 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.
///
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);
}
/// What a root's chip in the pane header says: C:, /, ~, or a mount's name.
///
///
/// A name, never a path — the full path is the chip's and its command
/// parameter, and it stays there. This used to be root.TrimEnd(separator), which is a name only
/// for a Windows drive: on Unix it made the / 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.
///
///
/// ~ 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.
///
///
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;
}
///
/// Gives up the vault, keeping the connection and anything in flight.
///
///
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
///
internal void Detach()
{
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.
/// Opens the picker, from the invitation in the empty remote pane.
[RelayCommand]
private void BeginChoosingRemote() => IsChoosingRemote = true;
///
/// Puts the picker away without connecting.
///
///
/// 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.
///
[RelayCommand]
private void CancelChoosingRemote()
{
IsChoosingRemote = false;
TypedPassword = string.Empty;
}
/// Opens the chosen remote, whichever kind it is.
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
Remote is RemoteKind.Bucket
? OpenBucketAsync(cancellationToken)
: ConnectToHostAsync(cancellationToken);
///
/// Opens the chosen bucket.
///
///
///
/// 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.
///
///
/// It goes through the same session field, the same queue and the same panes as a host, because by this
/// point it is an IRemoteFileStore like any other. Everything below this method was written for
/// SFTP and needed no change.
///
///
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);
}
/// Opens a file-transfer session on the chosen host.
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);
}
///
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
/// disconnect log closes out later.
///
///
/// Split out of for length rather than for reuse —
/// 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 rather than , which is typed
/// as IRemoteFileStore and does not carry either.
///
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);
}
///
/// Closes the file-transfer session.
///
///
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
/// accident.
///
[RelayCommand]
private async Task DisconnectAsync()
{
if (queue.IsBusy)
{
Status = "There are transfers still running. Stop them first, or let them finish.";
return;
}
await CloseSessionAsync().ConfigureAwait(true);
Status = "Disconnected. Anything already transferred is where it landed.";
}
/// Pins the offered host key and connects.
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation || knownHosts is null)
{
return;
}
try
{
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
return;
}
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
}
///
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
///
///
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
/// with two clicks instead of one.
///
[RelayCommand]
private void RejectHostKey()
{
var wasOffered = PendingHostKey is not null;
PendingHostKey = null;
HostKeyMismatch = null;
Status = wasOffered
? "The host key was not trusted, so nothing was connected."
: "Nothing was connected.";
}
// ---- Navigation ----
/// Goes to a remote directory.
[RelayCommand]
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
{
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
}
/// Goes up one remote directory.
[RelayCommand]
private async Task RemoteUpAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
}
}
/// Re-reads the remote directory.
[RelayCommand]
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
}
}
///
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
///
///
/// A symbolic link is tried as a directory: a listing carries lstat attributes, so a link to a
/// directory reports as a link and resolving every one of them would be a round trip per row. The
/// failure, when it is a link to a file, is the server's own and says so.
///
[RelayCommand]
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
{
if (SelectedRemoteEntry is { IsNavigable: true } row)
{
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
}
}
/// Goes to a local directory.
[RelayCommand]
private void GoLocal(string path) => NavigateLocal(path);
/// Goes up one local directory, as far as the top of the drive.
///
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
/// drive is , which is a list of places rather than a step upwards.
///
[RelayCommand]
private void LocalUp()
{
if (LocalDirectory.Parent(LocalPath) is { } parent)
{
NavigateLocal(parent);
return;
}
Status = "That is the top of this drive. Use the drive list to go to another one.";
}
/// Re-reads the local directory.
[RelayCommand]
private void RefreshLocal() => NavigateLocal(LocalPath);
/// Opens whatever is selected in the local pane, if it is a directory.
[RelayCommand]
private void OpenLocal()
{
if (SelectedLocalEntry is { IsNavigable: true } row)
{
NavigateLocal(row.FullPath);
}
}
// ---- Moving files ----
/// Queues the chosen remote file for download into the local directory showing.
[RelayCommand]
private void Download()
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose a file on the host to download.";
return;
}
QueueDownloads([row]);
}
/// Queues the chosen local file for upload into the remote directory showing.
[RelayCommand]
private void Upload()
{
if (SelectedLocalEntry is not { } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
QueueUploads([row.FullPath]);
}
///
/// Queues every one of these local paths for upload into the remote directory showing.
///
///
///
/// 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.
///
///
/// Directories are skipped and counted. 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.
///
///
/// Reported per item, not per drop. 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.
///
///
internal void QueueUploads(IReadOnlyList 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);
}
///
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
/// the transfer no longer needs them.
///
///
///
/// This exists for the phone, and the copy is not an implementation detail that could be avoided.
/// Android hands a chosen document over as a content:// 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.
///
///
/// Released on success and on discard, never on failure. 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.
///
///
internal void QueueStagedUploads(IReadOnlyList paths)
{
ArgumentNullException.ThrowIfNull(paths);
foreach (var path in paths)
{
staged.Add(path);
}
QueueUploads(paths);
}
///
/// 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.
///
///
///
/// The mirror of , and it exists for the same reason. 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 — supplied by the head
/// that raised the picker — copies the result out once there is a result to copy.
///
///
/// The destination is chosen before the transfer starts, not after. 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.
///
///
/// Delivery failure does not delete the bytes. 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.
///
///
/// The remote file to fetch.
/// Where to stage it — a path the head owns and will sweep.
/// Copies the staged file to wherever it was really meant to go.
internal void QueueDeliveredDownload(
RemoteEntryRowViewModel row,
string localPath,
Func 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.";
}
///
/// 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.
///
private async Task DeliverAsync(string localPath, Func 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}";
}
}
///
/// 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.
///
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)
{
}
}
/// Queues every one of these remote entries for download into the local directory showing.
///
internal void QueueDownloads(IReadOnlyList 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);
}
///
/// 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.
///
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;
}
/// Stops one transfer.
[RelayCommand]
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
/// Runs a stopped transfer again, resuming where there is something to resume from.
[RelayCommand]
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
/// Removes one stopped transfer and whatever it left behind.
[RelayCommand]
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
{
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
// removed anyway would take the only view of a transfer that was still running.
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
{
Transfers.Remove(row);
// 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);
}
}
/// Clears the finished transfers, which have nothing left on disk.
[RelayCommand]
private void ClearCompleted()
{
queue.ClearCompleted();
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
{
Transfers.Remove(row);
}
}
// ---- Changing the remote directory ----
/// Creates a directory on the host.
[RelayCommand]
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
{
var name = NewRemoteFolder.Trim();
if (name.Length == 0)
{
Status = "Type a name for the new directory.";
return;
}
if (name.Contains('/', StringComparison.Ordinal))
{
// One directory, whose parent must exist. A name with a separator in it would be a request to
// create a path, and this button creates a directory in the one on screen.
Status = "A directory name cannot contain '/'. Make one level at a time.";
return;
}
await RunAsync(
$"Creating {name}…",
async () =>
{
await RequireSession()
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
.ConfigureAwait(true);
NewRemoteFolder = string.Empty;
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"Created {name}.";
}).ConfigureAwait(true);
}
///
/// Asks whether the chosen remote file, or empty directory, should go.
///
///
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
/// what it is — the only thing this application destroys that neither the server nor this machine has a
/// copy of.
///
[RelayCommand]
private void DeleteRemote()
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose something on the host to delete.";
return;
}
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
}
///
/// Deletes what was agreed to.
///
///
/// Not recursive, and the refusal comes from the server rather than from a check here — see
/// ISftpSession.DeleteAsync. It acts on the path the question named rather than on the selection,
/// which is what makes the question a promise: nothing between asking and answering can point it
/// somewhere else.
///
[RelayCommand]
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
{
if (PendingRemoteDeletion is not { } request)
{
return;
}
PendingRemoteDeletion = null;
await RunAsync(
$"Deleting {request.Name}…",
async () =>
{
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"Deleted {request.Name}.";
}).ConfigureAwait(true);
}
/// Thinks better of it.
[RelayCommand]
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
///
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
queue.Changed -= OnTransferChanged;
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
// unwinding before the transport under it goes.
await queue.DisposeAsync().ConfigureAwait(false);
if (session is not null)
{
await session.DisposeAsync().ConfigureAwait(false);
session = null;
}
}
///
/// Goes to a remote directory, on its own.
///
///
/// Split from so that the commands which navigate as part of
/// something else — connecting, making a directory, deleting one — can list without going back through
/// the busy guard. returns immediately when a command is already running, so a
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
/// anywhere to explain it.
///
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
{
if (session is null)
{
Status = "Connect to a host first.";
return Task.CompletedTask;
}
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
}
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
{
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
RemotePath = path;
SelectedRemoteEntry = null;
RemoteEntries.Clear();
foreach (var entry in entries)
{
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
}
RemoteTrail.Clear();
foreach (var (name, crumb) in SftpPath.Trail(path))
{
RemoteTrail.Add(new CrumbViewModel(name, crumb));
}
Status = string.Empty;
}
///
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
/// something that returns before the click has finished being handled.
///
private void NavigateLocal(string path)
{
try
{
var entries = LocalDirectory.List(path);
LocalPath = Path.GetFullPath(path);
SelectedLocalEntry = null;
LocalEntries.Clear();
foreach (var entry in entries)
{
LocalEntries.Add(new LocalEntryRowViewModel(entry));
}
RebuildLocalTrail();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
// pane would look like a directory that had become empty.
Status = $"{path} could not be read: {exception.Message}";
}
}
///
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
/// is C:\ — a root with a separator inside it, which splitting turns into a crumb called
/// C: that navigates to the process's current directory on that drive rather than to its root.
///
private void RebuildLocalTrail()
{
var crumbs = new List();
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
{
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
}
LocalTrail.Clear();
foreach (var crumb in crumbs)
{
LocalTrail.Add(crumb);
}
}
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
///
/// Rebuilds both pickers from the vault's lists.
///
///
///
/// 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.
///
///
/// The selection is re-found by id rather than kept. 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.
///
///
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();
}
/// The session, or a failure a queue row can carry.
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);
}
///
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
/// instead of one for the first change and one for the rest.
///
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
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);
});
///
/// The same funnel VaultViewModel uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled.
///
private async Task RunAsync(string busyMessage, Func work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
///
/// 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.
///
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;
}
/// Raises the three flags that answer "is there a bucket to open".
///
/// 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.
///
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;
}
///
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
///
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
{
OnPropertyChanged(nameof(CanDownload));
PendingRemoteDeletion = null;
}
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
{
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
OnPropertyChanged(nameof(CanDeleteRemote));
}
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
OnPropertyChanged(nameof(CanUpload));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}