Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
T

4388 lines
187 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 partial class RemoteEntryRowViewModel(SftpEntry entry) : ObservableObject
{
/// <summary>
/// Whether this row is in the set the batch actions act on.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A tick of its own rather than the list control's selection, and the two are different things
/// here.</b> The selection is "the row I am looking at", which is what rename, move and permissions act
/// on — all three are one-at-a-time by construction and take a form that names one path. This is "the
/// rows I have gathered up", which is what downloading and deleting act on, and it survives the
/// selection moving.
/// </para>
/// <para>
/// It is also the only shape that works on both heads. A phone has no shift-click and no ctrl-click, so
/// a multi-selection there has to be a tap target either way — and one that is drawn is one somebody can
/// see the state of, which for a batch delete is worth more than the two keystrokes it costs a desk.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isChecked;
internal SftpEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsNavigable;
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
/// <remarks>
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
/// </remarks>
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
/// <remarks>
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
/// </remarks>
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
/// <remarks>
/// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket
/// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to
/// report for one and leaves the timestamp at its default. Formatting that prints
/// <c>0001-01-01 00:00</c>, which is not a quiet "unknown": it is a date, and a listing that states one
/// for every folder in a bucket is stating something untrue.
/// </remarks>
internal string Modified =>
entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
internal string Permissions => entry.Permissions;
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
internal bool IsExecutable => entry.IsExecutable;
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
internal bool IsWorldWritable => entry.IsWorldWritable;
}
/// <summary>One local file or directory, as a row.</summary>
/// <remarks>
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
/// </remarks>
internal sealed partial class LocalEntryRowViewModel(LocalEntry entry) : ObservableObject
{
/// <inheritdoc cref="RemoteEntryRowViewModel.IsChecked" />
[ObservableProperty]
private bool isChecked;
internal LocalEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsDirectory;
internal bool IsFile => !entry.IsDirectory;
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}
/// <summary>How this screen writes a modification time.</summary>
/// <remarks>
/// <para>
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
/// </para>
/// <para>
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
/// </para>
/// </remarks>
internal static class Timestamps
{
internal static string Format(DateTimeOffset moment) =>
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
/// <summary>One transfer, as a row in the queue.</summary>
/// <remarks>
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
/// </remarks>
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
[ObservableProperty]
private TransferSnapshot transfer = snapshot;
internal Guid Id => Transfer.Id;
internal string Name => Transfer.Name;
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
internal string Path => Transfer.Direction is TransferDirection.Download
? Transfer.RemotePath
: Transfer.LocalPath;
/// <summary>
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
/// </summary>
/// <remarks>
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
/// instead.
/// </remarks>
internal string Label
{
get
{
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
// translation to name the file at the end of it.
var source = System.IO.Path.GetFileName(sourcePath);
return $"{source} → {destinationDirectory}";
}
}
internal double Percent => Transfer.Fraction * 100;
/// <summary>
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
/// </summary>
/// <remarks>
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
/// the bar alone cannot state exactly.
/// </remarks>
internal string StatusWord => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running => string.Create(
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
TransferState.Completed => "done",
TransferState.Cancelled => "stopped",
_ => "failed",
};
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
internal bool IsInProgress => Transfer.State is TransferState.Running;
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
internal bool IsDone => Transfer.State is TransferState.Completed;
/// <summary>
/// What the row says about where it has got to.
/// </summary>
/// <remarks>
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
/// whether it is still moving.
/// </remarks>
internal string Progress => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running =>
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
TransferState.Completed => ByteSize.Format(Transfer.Length),
TransferState.Cancelled when Transfer.Transferred > 0 =>
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
TransferState.Cancelled => "stopped",
_ => Transfer.Failure ?? "failed",
};
internal string StateLabel => Transfer.State switch
{
TransferState.Queued => "QUEUED",
TransferState.Running => "RUNNING",
TransferState.Completed => "DONE",
TransferState.Cancelled => "STOPPED",
_ => "FAILED",
};
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
internal bool IsFinished => Transfer.IsFinished;
internal bool CanResume => Transfer.CanResume;
internal bool HasFailed => Transfer.State is TransferState.Failed;
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
/// <remarks>
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
/// wording differs. See <see cref="RetryLabel"/>.
/// </remarks>
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
/// <remarks>
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
/// raising only the ones that happened to change is how a progress bar moves under a label that still
/// says "queued".
/// </remarks>
partial void OnTransferChanged(TransferSnapshot value)
{
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StatusWord));
OnPropertyChanged(nameof(IsInProgress));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsFinished));
OnPropertyChanged(nameof(CanResume));
OnPropertyChanged(nameof(CanRetry));
OnPropertyChanged(nameof(HasFailed));
OnPropertyChanged(nameof(RetryLabel));
}
}
/// <summary>
/// Something on the host that has been asked about and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
/// has no wastebasket to put them in.
/// </para>
/// <para>
/// It carries the full path rather than only the name, because the name is the half that does not identify
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
/// </para>
/// <para>
/// <b>It covers however many were ticked, and it names them all.</b> A batch delete that said "3 items" and
/// nothing else would be asking somebody to agree to a number — the paths are what makes the question
/// answerable, and they are the whole of what this form has to show for the risk it is taking.
/// </para>
/// </remarks>
/// <param name="Paths">
/// What is being deleted, each with the name and kind it was listed under. Never empty: the command that
/// builds one refuses before it gets here.
/// </param>
internal sealed record RemoteDeletionRequest(IReadOnlyList<RemoteDeletionTarget> Paths)
{
/// <summary>The one path, when there is one — which is every question this screen asked before batches.</summary>
internal RemoteDeletionTarget First => Paths[0];
/// <summary>How many.</summary>
internal int Count => Paths.Count;
/// <summary>Whether this is the ordinary one-row question.</summary>
internal bool IsSingle => Count == 1;
/// <summary>Whether every one of them is a directory, which changes what the host will refuse.</summary>
private bool AllDirectories => Paths.All(target => target.IsDirectory);
/// <summary>The question, naming the kind because the two behave differently.</summary>
internal string Question => IsSingle
? First.IsDirectory
? $"Delete the directory '{First.Name}' on the host?"
: $"Delete '{First.Name}' on the host?"
: $"Delete {Count} items on the host?";
/// <summary>
/// What is about to go, one path per line.
/// </summary>
/// <remarks>
/// The full paths rather than the names, for the reason this record carries them: <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.
/// </remarks>
internal string Listing => string.Join('\n', Paths.Select(target => target.FullPath));
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
internal string Consequence => AllDirectories
? "Removed on the host itself. The host refuses a directory that still has anything in it, so this "
+ "either removes empty ones or fails — and what goes is gone: nothing here keeps a copy and "
+ "there is no undo."
: "Removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
+ "touched, and there is no undo.";
}
/// <summary>One thing a deletion question is about.</summary>
/// <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 RemoteDeletionTarget(string Name, string FullPath, bool IsDirectory);
/// <summary>Which of the two things a rename actually is, from where the user is standing.</summary>
/// <remarks>
/// One SFTP operation underneath — <c>ISftpSession.RenameAsync</c> is the whole of both — and two separate
/// questions on the screen, because the answer a person types is a different kind of thing each time. A
/// rename takes a name and must not take a path; a move takes a directory and must not take a name. Folding
/// them into one box asking for a destination path would be fewer controls and a worse question: the two
/// mistakes it invites — typing a directory where a name goes, or a name where a directory goes — are
/// exactly the ones this split makes impossible to make.
/// </remarks>
internal enum RemoteMoveKind
{
/// <summary>Same directory, different name.</summary>
Rename,
/// <summary>Same name, different directory.</summary>
Move,
}
/// <summary>
/// Something on the host that is being given a new name or a new home, and has not been committed yet.
/// </summary>
/// <remarks>
/// <para>
/// The shape <see cref="RemoteDeletionRequest"/> already established, and here for the same reason: it
/// carries the full path rather than the name, so nothing that happens between opening the form and
/// pressing its button can point the operation at a different file. The selection can move underneath it —
/// a refresh nulls it — and the request stays about what was asked about.
/// </para>
/// <para>
/// <b>It asks rather than warns.</b> Renaming and moving on the host are not deletions: what they change is
/// where the bytes are reached from, and both are undone by doing them again the other way. So this form
/// carries a field and a verb instead of <see cref="RemoteDeletionRequest.Consequence"/>'s "there is no
/// undo" — the one warning on this screen that is earned, and worth not spending twice.
/// </para>
/// </remarks>
/// <param name="Name">What the row is called now.</param>
/// <param name="FullPath">Where it is now, which is the end of the operation nothing may re-point.</param>
/// <param name="IsDirectory">Whether it is a directory, which changes what moving it can be asked to do.</param>
/// <param name="Kind">Which question is being asked.</param>
internal sealed record RemoteMoveRequest(string Name, string FullPath, bool IsDirectory, RemoteMoveKind Kind)
{
/// <summary>Whether this is the rename question rather than the move one.</summary>
internal bool IsRename => Kind is RemoteMoveKind.Rename;
/// <summary>The heading, which names the thing rather than only the verb.</summary>
internal string Question => IsRename
? $"Rename '{Name}' on the host"
: $"Move '{Name}' to another directory";
/// <summary>
/// What an empty box shows, which is the shape of the answer rather than an example of one.
/// </summary>
/// <remarks>
/// The placeholder is the whole of what says which of the two kinds of answer the box wants — there is
/// no label above it. A field label would be a second line saying what this line already says, and the
/// pane this form opens in is 204 pixels wide inside the session shell: every line it does not need is
/// a line of the listing underneath that survives.
/// </remarks>
internal string FieldHint => IsRename ? "new name" : "/path/to/directory";
/// <summary>What the button that commits it says.</summary>
internal string ConfirmLabel => IsRename ? "RENAME" : "MOVE";
/// <summary>
/// What it does, in the one sentence the form has room for.
/// </summary>
/// <remarks>
/// Both halves state the refusal rather than the action, because the action is already in the heading
/// and the refusal is the part nobody expects: SFTP's rename does not replace, so a name already taken
/// is a failure rather than a silent overwrite — which is the same promise the transfer queue makes and
/// rests on the same server behaviour.
/// </remarks>
internal string Note => IsRename
? "A name already taken is refused, not overwritten."
: "Relative to the directory showing. A name already taken is refused, not overwritten.";
}
/// <summary>
/// A path on the host whose mode is being changed, and has not been committed yet.
/// </summary>
/// <remarks>
/// <para>
/// The shape <see cref="RemoteDeletionRequest"/> and <see cref="RemoteMoveRequest"/> established — the full
/// path is carried so that nothing which happens between opening the form and pressing its button can
/// re-point the operation — and unlike those two it is mutable, because the answer is nine checkboxes
/// rather than a typed string.
/// </para>
/// <para>
/// ◆ <b>Nine boxes rather than a box to type <c>755</c> into, and that is the whole design of this form.</b>
/// The mode is already drawn as <c>-rwxr-xr-x</c> in the column beside it, so a grid of who-can-do-what is
/// the same representation the reader is already looking at; an octal field would ask them to translate a
/// thing they can see into a thing they cannot check. The octal is printed underneath as it changes, which
/// is what makes the two readings verifiable against each other rather than a choice between them.
/// </para>
/// <para>
/// <b>The three bits above the nine are not here.</b> <c>PosixMode</c> neither renders nor writes setuid,
/// setgid or the sticky bit — see its own remark — so a form that offered them would be writing something
/// the listing cannot show, and one that silently cleared them would be worse. What this sends is the nine
/// bits and a documented zero above them.
/// </para>
/// </remarks>
internal sealed partial class RemotePermissionsRequest : ObservableObject
{
internal RemotePermissionsRequest(string name, string fullPath, bool isDirectory, string mode)
{
Name = name;
FullPath = fullPath;
IsDirectory = isDirectory;
Original = mode;
var bits = PosixMode.ToOctal(mode);
ownerRead = (bits & 0b100_000_000) != 0;
ownerWrite = (bits & 0b010_000_000) != 0;
ownerExecute = (bits & 0b001_000_000) != 0;
groupRead = (bits & 0b000_100_000) != 0;
groupWrite = (bits & 0b000_010_000) != 0;
groupExecute = (bits & 0b000_001_000) != 0;
othersRead = (bits & 0b000_000_100) != 0;
othersWrite = (bits & 0b000_000_010) != 0;
othersExecute = (bits & 0b000_000_001) != 0;
}
/// <summary>What the row is called, which is what the heading names.</summary>
internal string Name { get; }
/// <summary>Where it is, which is the end of the operation nothing may re-point.</summary>
internal string FullPath { get; }
/// <summary>Whether it is a directory, which changes what the execute bit is called.</summary>
internal bool IsDirectory { get; }
/// <summary>The mode as the listing found it, so the form can say what it is about to change.</summary>
internal string Original { get; }
[ObservableProperty]
private bool ownerRead;
[ObservableProperty]
private bool ownerWrite;
[ObservableProperty]
private bool ownerExecute;
[ObservableProperty]
private bool groupRead;
[ObservableProperty]
private bool groupWrite;
[ObservableProperty]
private bool groupExecute;
[ObservableProperty]
private bool othersRead;
[ObservableProperty]
private bool othersWrite;
[ObservableProperty]
private bool othersExecute;
/// <summary>The heading, which names the thing rather than only the verb.</summary>
internal string Question => $"Permissions for '{Name}'";
/// <summary>The nine bits as they now stand, ready for the wire.</summary>
internal int Mode =>
(OwnerRead ? 0b100_000_000 : 0)
| (OwnerWrite ? 0b010_000_000 : 0)
| (OwnerExecute ? 0b001_000_000 : 0)
| (GroupRead ? 0b000_100_000 : 0)
| (GroupWrite ? 0b000_010_000 : 0)
| (GroupExecute ? 0b000_001_000 : 0)
| (OthersRead ? 0b000_000_100 : 0)
| (OthersWrite ? 0b000_000_010 : 0)
| (OthersExecute ? 0b000_000_001 : 0);
/// <summary>
/// What the form is about to send, in both spellings at once.
/// </summary>
/// <remarks>
/// The <c>ls</c> rendering and the <c>chmod</c> number side by side, because the two audiences for this
/// form are the person reading the column and the person who would otherwise have typed the number —
/// and printing both is what lets either check the boxes they just ticked.
/// </remarks>
internal string Preview => $"{Rendered} ({PosixMode.FormatOctal(Mode)})";
/// <summary>Whether anything has actually been changed, which is what the button is enabled on.</summary>
/// <remarks>
/// A round trip that sets a mode to the mode it already has is not wrong, only pointless — and a button
/// that stays live over a form nobody has touched invites exactly that.
/// </remarks>
internal bool HasChanges => !string.Equals(Rendered, Original, StringComparison.Ordinal);
/// <summary>What the execute column means here, which is not the same thing for a directory.</summary>
/// <remarks>
/// On a directory the execute bit is permission to <em>enter</em> it — to resolve a name inside — and a
/// column headed "exec" over a folder is the commonest misreading of a POSIX mode there is. It costs one
/// word to say which is meant.
/// </remarks>
internal string ExecuteLabel => IsDirectory ? "enter" : "exec";
private string Rendered => PosixMode.Format(
IsDirectory ? SftpEntryKind.Directory : SftpEntryKind.File,
OwnerRead,
OwnerWrite,
OwnerExecute,
GroupRead,
GroupWrite,
GroupExecute,
OthersRead,
OthersWrite,
OthersExecute);
/// <remarks>
/// One hook for all nine, because the two things a view reads off this form — the preview line and
/// whether the button is live — are functions of the whole mode rather than of any one bit.
/// </remarks>
partial void OnOwnerReadChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnOwnerWriteChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnOwnerExecuteChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnGroupReadChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnGroupWriteChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnGroupExecuteChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnOthersReadChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnOthersWriteChanged(bool value) => Restring();
/// <inheritdoc cref="OnOwnerReadChanged" />
partial void OnOthersExecuteChanged(bool value) => Restring();
private void Restring()
{
OnPropertyChanged(nameof(Preview));
OnPropertyChanged(nameof(HasChanges));
}
}
/// <summary>
/// A remote file that has been fetched, opened in a local editor, and is being watched for saves.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The one thing this screen does that is neither a listing nor a transfer.</b> Editing a config on a
/// host is the commonest reason somebody opens a file browser and then gives up and opens a terminal, and
/// the round trip it replaces is: download, find the file, open it, edit, save, come back, delete the old
/// one because nothing here overwrites, upload, check the mode survived.
/// </para>
/// <para>
/// <b>It remembers what the server said when the copy was taken.</b> Not to be clever about merging —
/// nothing here merges — but so that a write-back can refuse when the file has moved underneath it. Two
/// people editing one <c>nginx.conf</c> is the ordinary way a config gets silently reverted, and the only
/// honest thing a client this thin can do is notice and ask.
/// </para>
/// </remarks>
/// <param name="Name">What the file is called, for the strip's heading.</param>
/// <param name="RemotePath">Where it came from, which is where a save goes back to.</param>
/// <param name="LocalCopy">The temporary file the editor has open. This screen made it and will remove it.</param>
/// <param name="Mode">The mode it had, so a write-back can put it back. Zero when there was none to read.</param>
internal sealed record RemoteEditSession(
string Name,
string RemotePath,
string LocalCopy,
int Mode)
{
/// <summary>What the server says the file is, as of the last time this session looked.</summary>
/// <remarks>
/// <para>
/// Length and timestamp rather than a digest, because a digest is a second full read of a file this
/// client has no other reason to re-fetch. The pair catches every case anybody has actually run into —
/// somebody else saving, a deploy replacing it, a process rewriting it — and misses only a change that
/// preserved both, which SFTP gives no cheap way to see at all.
/// </para>
/// <para>
/// ◆ <b>Settable, and the record is mutated rather than replaced.</b> It moves after every write-back,
/// so the next save compares against what this one produced rather than against the original and
/// reports its own change as somebody else's. Replacing the record with <c>with</c> would have been the
/// tidier expression and is wrong: the watcher's callback closes over the instance it was started for
/// and checks it is still the current one, so a new instance silently turns every save after the first
/// into a no-op.
/// </para>
/// </remarks>
internal (long Length, DateTimeOffset Written) Stamp { get; set; }
/// <summary>The heading, which names the file rather than only the verb.</summary>
internal string Question => $"Editing '{Name}'";
}
/// <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>
/// Opens a local path in whatever this machine opens that kind of file with, or null on a head that has
/// no such notion.
/// </summary>
/// <remarks>
/// A delegate for the reason <c>copyToClipboard</c> is one: "open this with the default application" is
/// a platform call, and the view model that decides <em>when</em> to make it has no business knowing
/// how. Null is an honest answer rather than a gap — see <see cref="CanEditRemote"/>.
/// </remarks>
private readonly Func<string, Task>? openInEditor;
/// <summary>
/// Watches one local file and calls back when it changes, until the returned handle is disposed — or
/// null on a head that cannot.
/// </summary>
/// <remarks>
/// ◆ <b>Injected rather than a <c>FileSystemWatcher</c> built here, and that is what makes editing
/// testable at all.</b> A watcher is a platform object with its own thread, its own buffer and its own
/// notion of when a save has finished; a test that waited on one would be measuring the operating
/// system. What this view model is responsible for is what happens <em>when</em> a save arrives, and a
/// fake that fires on demand is what lets that be asserted.
/// </remarks>
private readonly Func<string, Action, IDisposable>? watchFile;
/// <summary>The watcher for the file being edited, or null when nothing is.</summary>
private IDisposable? editWatch;
/// <summary>
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
/// <see cref="QueueDeliveredDownload"/>.
/// </summary>
/// <remarks>
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
/// per transfer on any head that ever normalises one.
/// </remarks>
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// What to do with a completed download whose real destination this layer cannot write to.
/// </summary>
/// <remarks>
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
/// download that failed once and succeeded on the second attempt must still end up where the person
/// pointed. See <see cref="QueueDeliveredDownload"/>.
/// </remarks>
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private IRemoteFileStore? session;
private ConnectionRecorder? connectionLog;
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
private IObjectStoreFactory? objectStores;
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
/// <remarks>
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
/// row it came from may have been replaced by a background sync. The address is the one that was
/// actually dialled, which is the whole point of capturing it at connect.
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
/// <summary>
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
/// connected.
/// </summary>
/// <remarks>
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
/// the first, for no reader that could not already reach it here.
/// </remarks>
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
private bool disposed;
/// <summary>Whether the remote listing on screen is known to be out of date.</summary>
/// <remarks>
/// Set by <see cref="NoteTransferLanded"/> and cleared by any listing of the directory it is about.
/// Read back by <see cref="RemoteListingIsStale"/>, which exists so that the deferral this drives can be
/// asserted on in the state where it defers — the moment a transfer lands over an open question is not
/// one a test can otherwise see the inside of.
/// </remarks>
private bool remoteListingIsStale;
/// <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>
/// <param name="openInEditor"><inheritdoc cref="openInEditor" path="/summary"/></param>
/// <param name="watchFile"><inheritdoc cref="watchFile" path="/summary"/></param>
internal TransfersViewModel(
ISftpSessionFactory sftp,
TimeProvider clock,
Action<Action>? post = null,
Action? addBucket = null,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null)
{
this.sftp = sftp;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.addBucket = addBucket;
this.openInEditor = openInEditor;
this.watchFile = watchFile;
// The supplier answers with whatever session is current at the moment a transfer starts, which is
// what lets a queue survive a disconnect and reconnect without every queued row failing.
queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
// which is the life of the process.
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
Transfers.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasTransfers));
OnPropertyChanged(nameof(ActiveTransfersLabel));
};
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
// vault on every synchronisation pass, so the invitation these flags decide between has to change
// with them rather than at the moment somebody pressed something. That is the path that matters —
// adding the first bucket happens on another screen, and this one has to notice when it comes back.
Buckets.CollectionChanged += (_, _) => RaiseBucketState();
}
/// <summary>Where a bucket is made, or null in a head with nowhere to send anybody.</summary>
private readonly Action? addBucket;
/// <summary>Goes to the keychain with the bucket editor open.</summary>
/// <remarks>
/// The dead end this replaces was a real one: SELECT BUCKET opened a picker with nothing in it, and
/// nothing anywhere on the screen said that a bucket is made on the keychain screen. Somebody who had
/// come to S3 to add one had arrived at the place it is used and been shown no route to the place it is
/// created.
/// </remarks>
[RelayCommand]
private void AddBucket()
{
// Closed first, so returning here from the keychain does not find a picker still open over a list
// that has since gained the bucket it was empty of.
IsChoosingRemote = false;
addBucket?.Invoke();
}
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
/// <remarks>
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
/// </remarks>
internal ReplaceableCollection<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 ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
/// <remarks>
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
/// only when a person changes the picker, and is what every refresh looks for.
/// </remarks>
private Guid? chosenHost;
/// <inheritdoc cref="chosenHost" path="/remarks" />
private Guid? chosenBucket;
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
private bool refreshingPickers;
/// <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;
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
/// <remarks>
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
/// host it was not typed for.
/// </remarks>
private Guid? typedPasswordFor;
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
/// <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>
/// Why the connection went away on its own, or null when it did not.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Not the same state as "not connected", which is what this screen used to collapse it into.</b>
/// Nothing connected is where the screen starts and where DISCONNECT leaves it: an invitation, two empty
/// panes, nothing to explain. This is a session that was working and stopped, which is worth three
/// things the invitation is not — the sentence saying what happened, the listing left on screen so the
/// user can see where they were, and a button that puts them back there.
/// </para>
/// <para>
/// Set only by <see cref="OnSessionConnectionLost"/>, which is the store's own event. An operation that
/// merely failed does not set it: a permission denied on one path is not a connection, and a screen that
/// said DISCONNECTED over a directory it simply may not read would be worse than one that said nothing.
/// </para>
/// </remarks>
[ObservableProperty]
private string? lostConnection;
/// <summary>Whether the connection went away on its own and has not been re-established.</summary>
internal bool IsConnectionLost => LostConnection is { Length: > 0 };
/// <summary>
/// Whether the remote pane has a listing worth drawing, connected or not.
/// </summary>
/// <remarks>
/// ◆ <b>The half of a lost connection the panes have to know about.</b> Every "is there anything here"
/// binding on both heads used to read <see cref="IsConnected"/>, which was right while the only way to
/// stop being connected was to press DISCONNECT — that empties the pane, so an invitation drawn over it
/// covered nothing. A connection lost underneath somebody does not empty it, and an invitation over
/// <em>that</em> would hide the one thing worth keeping: where they were, which is what the reconnect
/// beside it is offering to put them back into.
/// </remarks>
internal bool ShowsRemoteContents => IsConnected || IsConnectionLost;
/// <summary>Whether the remote pane should offer to open something instead of listing one.</summary>
/// <remarks>The other side of <see cref="ShowsRemoteContents"/>, so the two can never be drawn at once.</remarks>
internal bool ShowsRemoteInvitation => !ShowsRemoteContents;
/// <summary>
/// What a reconnect would dial, captured at the moment the last connection was made.
/// </summary>
/// <remarks>
/// <para>
/// Captured rather than read off the pickers when the button is pressed, and for the reason
/// <see cref="connected"/> is captured: the selection is a control somebody can move, and a reconnect
/// that dialled whatever happened to be lit would send them to a different machine than the one whose
/// listing is on the screen in front of them.
/// </para>
/// <para>
/// <b>The path is deliberately not in here.</b> It would have to be kept up to date with every
/// navigation, and there is already something that is: the pane itself. A lost connection leaves its
/// listing on screen — see <see cref="OnSessionConnectionLost"/> — so where to go back to is whatever
/// <see cref="RemotePath"/> still says, which is by construction the last directory that was actually
/// reached rather than a copy of it that could disagree.
/// </para>
/// </remarks>
private (RemoteKind Kind, Guid EntityId)? reconnectTo;
/// <summary>
/// Whether something is being dragged over the local pane, and whether it would be accepted.
/// </summary>
/// <remarks>
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
/// window that has stopped responding.
/// </remarks>
[ObservableProperty]
private bool isLocalDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropRefused;
/// <summary>The account and endpoint actually dialled, once connected.</summary>
[ObservableProperty]
private string? connectedTo;
/// <summary>
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
/// while nothing is connected, or while what is connected is a bucket rather than a host.
/// </summary>
/// <remarks>
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
/// </remarks>
[ObservableProperty]
private string? connectedCipher;
/// <summary>
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
/// service question, not the desktop's.
/// </summary>
/// <remarks>
/// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
/// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
/// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
/// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
/// desktop has no such question because nothing stops its process for having gone quiet.
/// </remarks>
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
[ObservableProperty]
private string? connectedHostKeyAlgorithm;
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did, or
/// null while nothing is connected.
/// </summary>
/// <remarks>
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
/// the one that actually authenticated.
/// </remarks>
[ObservableProperty]
private string? connectedIdentityLabel;
/// <summary>
/// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws
/// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See
/// <see cref="VaultViewModel.EditorPinnedPaths"/> for where a pin is actually added or removed; this is
/// a read of what was already saved there.
/// </summary>
/// <remarks>
/// Captured at connect, the same moment <see cref="ConnectedTo"/> is, rather than followed live off the
/// host row's own <c>PinnedPaths</c>. A pin edited while this session stays open shows up on the next
/// connect rather than mid-session — the same lag <see cref="ConnectedTo"/> itself already carries for
/// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying
/// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all:
/// <see cref="OpenBucketAsync"/> leaves this empty rather than reading as "not yet known", which is what
/// empty already means for a host that connected with none pinned.
/// </remarks>
internal ObservableCollection<string> ConnectedPinnedPaths { get; } = [];
/// <summary>Whether the connected host or bucket has any pins to draw as chips.</summary>
/// <remarks>
/// A read of <see cref="ConnectedPinnedPaths"/> rather than an <c>[ObservableProperty]</c> of its own,
/// so it is raised by hand at each of the three places that collection is repopulated or cleared —
/// <see cref="MarkHostConnected"/>, <see cref="OpenBucketAsync"/> and <see cref="CloseSessionAsync"/>.
/// </remarks>
internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
/// <remarks>
/// The resolved binding, for the reason its counterpart on <c>VaultViewModel</c> gives: a host naming
/// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen
/// has its own password box and so needs its own copy of the question — but it must give the same
/// answer, or one screen would ask for a password the other knew was not wanted.
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
ShowsHostPicker
&& (SelectedHost is null
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& 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; } = [];
/// <summary>
/// What is in the pane's path box, which is not where the pane is.
/// </summary>
/// <remarks>
/// <para>
/// A draft beside <see cref="RemotePath"/> rather than the path itself made editable, and the two facts
/// really are different: <see cref="RemotePath"/> is the directory that was listed, and this is what
/// somebody is halfway through typing. Binding the box straight to the path would navigate on every
/// keystroke — <c>/</c>, then <c>/v</c>, then <c>/va</c> — which is a round trip and a failed listing
/// per character.
/// </para>
/// <para>
/// It is written back from <see cref="ListRemoteAsync"/>, so every other way of moving — a breadcrumb,
/// UP, a pin, connecting — puts the real path back in the box. What a failed listing leaves is what was
/// typed, on purpose: the path that did not resolve is the one worth having in front of you to correct.
/// </para>
/// </remarks>
[ObservableProperty]
private string remotePathDraft = string.Empty;
[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>The rename or move being filled in, or null when none is.</summary>
[ObservableProperty]
private RemoteMoveRequest? pendingRemoteMove;
/// <summary>
/// What has been typed into whichever of the two forms is open — a name, or a directory.
/// </summary>
/// <remarks>
/// One box for both, because only one of the two forms is ever open: they replace each other, and a
/// second field would be a second thing to clear at every place this one is. Seeded from the row when
/// the form opens — the current name for a rename, the current directory for a move — so that both
/// questions start from the answer that changes nothing, and what somebody edits is the part they came
/// to change.
/// </remarks>
[ObservableProperty]
private string remoteMoveTarget = string.Empty;
internal bool IsMovingRemote => PendingRemoteMove is not null;
/// <summary>The mode being changed, or null when none is.</summary>
[ObservableProperty]
private RemotePermissionsRequest? pendingRemotePermissions;
internal bool IsChangingRemotePermissions => PendingRemotePermissions is not null;
/// <summary>The file being edited locally, or null when none is.</summary>
[ObservableProperty]
private RemoteEditSession? remoteEdit;
/// <summary>Whether a file is open in a local editor and being watched.</summary>
internal bool IsEditingRemote => RemoteEdit is not null;
/// <summary>
/// What the edit strip has to say — each save, most recent first, or why one did not go.
/// </summary>
/// <remarks>
/// A log rather than a single line, because the whole point of watching is that saving happens more than
/// once and each one is a round trip somebody may want to see happened. Bounded: an editor that
/// autosaves would otherwise grow this without limit for as long as the file is open.
/// </remarks>
internal ObservableCollection<string> RemoteEditLog { get; } = [];
/// <summary>
/// Set when the server's copy moved underneath the one being edited, and cleared by answering.
/// </summary>
/// <remarks>
/// The one state in which a save is <em>not</em> written back automatically. Two people editing one
/// config is how a change gets silently reverted, and a client this thin cannot merge — what it can do
/// is stop, say so, and offer the three answers that are actually available.
/// </remarks>
[ObservableProperty]
private string? remoteEditConflict;
/// <summary>Whether the file being edited has moved on the host since the copy was taken.</summary>
internal bool HasRemoteEditConflict => RemoteEditConflict is { Length: > 0 };
/// <summary>
/// Whether EDIT is offered on the chosen row.
/// </summary>
/// <remarks>
/// Four conditions, and the last is the one worth naming: the head has to have given this screen a way
/// to open a file and a way to watch it. The phone has neither — "the default application" is a chooser
/// there and a file it hands to another app is a copy it does not get told about — so the button is
/// simply not live on that head rather than there and disappointing.
/// </remarks>
internal bool CanEditRemote =>
IsConnected
&& !IsAskingAboutRemoteEntry
&& SelectedRemoteEntry is { IsFile: true }
&& openInEditor is not null
&& watchFile is not null;
/// <summary>
/// Whether the pane is waiting on an answer about the selected row.
/// </summary>
/// <remarks>
/// The three item buttons and the automatic refresh all read this rather than the two flags separately.
/// Deleting, renaming and moving are one at a time by construction — each form's own opening clears the
/// other — and what this names is the state where none of them may be started and nothing may pull the
/// listing out from under the question. See <see cref="SettleRemoteListingAsync"/>.
/// </remarks>
internal bool IsAskingAboutRemoteEntry =>
IsConfirmingRemoteDeletion || IsMovingRemote || IsChangingRemotePermissions;
/// <summary>How many saves the strip keeps before it starts dropping the oldest.</summary>
/// <remarks>
/// Enough to see a session's worth of saves and short enough that an editor autosaving every thirty
/// seconds cannot turn this into a memory leak with a scrollbar.
/// </remarks>
private const int EditLogDepth = 20;
/// <summary>Whether the pane's DELETE is live.</summary>
/// <remarks>
/// Off while any of the three questions is up, so a second press cannot arm a second one behind the
/// form — and disabled rather than hidden, because these buttons sit in a row and a gap where one was
/// would move the other two out from under the pointer.
/// </remarks>
internal bool CanDeleteRemote => IsConnected && !IsAskingAboutRemoteEntry;
/// <inheritdoc cref="CanDeleteRemote" />
internal bool CanRenameRemote => CanDeleteRemote;
/// <inheritdoc cref="CanDeleteRemote" />
internal bool CanMoveRemote => CanDeleteRemote;
/// <summary>
/// Whether the pane's PERMS is live.
/// </summary>
/// <remarks>
/// The three conditions the others have, and one more: a bucket has no mode to change. An object is
/// governed by an access policy rather than nine bits — <c>S3FileStore</c> refuses this outright — so the
/// button is dark on that destination rather than live and answered with a refusal.
/// </remarks>
internal bool CanChangeRemotePermissions => CanDeleteRemote && Remote is RemoteKind.Host;
/// <summary>
/// Whether the pane shows the row of things that can be done to the chosen row.
/// </summary>
/// <remarks>
/// Gone while one of the three questions is up, rather than merely disabled, and the phone head does
/// the same with its own buttons for the same reason: the form is the answer to the question those
/// buttons ask, and a pane 204 pixels wide inside the session shell has to spend the height on one or
/// the other. What is not on screen is not a control somebody can press by mistake either.
/// </remarks>
internal bool ShowsRemoteItemActions => IsConnected && !IsAskingAboutRemoteEntry;
/// <summary>Whether a transfer has landed in the directory showing and it has not been re-read yet.</summary>
/// <remarks>See <see cref="SettleRemoteListingAsync"/> for when that is allowed to be true for a while.</remarks>
internal bool RemoteListingIsStale => remoteListingIsStale;
/// <summary>
/// What the item row says it is about — the chosen row's name, or the sentence that says to choose one.
/// </summary>
/// <remarks>
/// A name rather than a full path, unlike the questions the buttons open: this line sits directly above
/// the listing with the row it names highlighted in it, so the disambiguation a confirmation needs is
/// already on screen. The forms carry the path, because by then the answer is being committed rather
/// than merely looked at.
/// </remarks>
internal string SelectedRemoteName => SelectedRemoteEntry?.Name ?? "Choose a row to act on it";
/// <summary>
/// The rows a batch action would act on: whatever is ticked, or the selection when nothing is.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The fallback is what keeps the one-file case exactly as it was.</b> Ticking is for gathering
/// several up; somebody who has clicked a row and pressed DOWNLOAD has not ticked anything and means
/// that row. Without this the commonest use of the screen would have grown a step, which is the usual
/// way a multi-select makes a tool worse.
/// </para>
/// <para>
/// Read fresh each time rather than kept as a collection. The ticks live on the rows, the rows are
/// replaced wholesale by every listing, and a second list to keep in step with that is a second thing to
/// get wrong — see <see cref="ListRemoteAsync"/>, which simply builds new rows.
/// </para>
/// </remarks>
internal IReadOnlyList<RemoteEntryRowViewModel> RemoteBatch
{
get
{
var ticked = RemoteEntries.Where(row => row.IsChecked).ToArray();
return ticked.Length > 0
? ticked
: SelectedRemoteEntry is { } row ? [row] : [];
}
}
/// <inheritdoc cref="RemoteBatch" />
internal IReadOnlyList<LocalEntryRowViewModel> LocalBatch
{
get
{
var ticked = LocalEntries.Where(row => row.IsChecked).ToArray();
return ticked.Length > 0
? ticked
: SelectedLocalEntry is { } row ? [row] : [];
}
}
/// <summary>How many rows are ticked on the host's side, or zero when none are.</summary>
/// <remarks>
/// The tick count rather than <see cref="RemoteBatch"/>'s, deliberately: this is what the strip prints
/// beside SELECT ALL, and a screen that said "1 selected" over a listing with nothing ticked — because
/// a row happened to be highlighted — would be describing a state the checkboxes visibly contradict.
/// </remarks>
internal int CheckedRemoteCount => RemoteEntries.Count(row => row.IsChecked);
/// <inheritdoc cref="CheckedRemoteCount" />
internal int CheckedLocalCount => LocalEntries.Count(row => row.IsChecked);
/// <summary>Whether anything is ticked on the host's side.</summary>
internal bool HasCheckedRemote => CheckedRemoteCount > 0;
/// <inheritdoc cref="HasCheckedRemote" />
internal bool HasCheckedLocal => CheckedLocalCount > 0;
/// <summary>What the host pane's selection strip says, or empty when there is nothing to say.</summary>
internal string CheckedRemoteLabel => CheckedRemoteCount switch
{
0 => string.Empty,
1 => "1 selected",
var many => $"{many} selected",
};
/// <inheritdoc cref="CheckedRemoteLabel" />
internal string CheckedLocalLabel => CheckedLocalCount switch
{
0 => string.Empty,
1 => "1 selected",
var many => $"{many} selected",
};
/// <summary>
/// Ticks every row in the host's listing, or unticks them when they already all are.
/// </summary>
/// <remarks>
/// One command for both directions rather than two buttons, because "select all" and "select none" are
/// the same gesture from the user's side — they look at the boxes and want the other state. Directories
/// are ticked along with files; what happens to one is the batch action's business, and a select-all
/// that quietly skipped rows would leave boxes unticked with no way to tick them.
/// </remarks>
[RelayCommand]
private void ToggleAllRemote()
{
var wanted = CheckedRemoteCount < RemoteEntries.Count;
foreach (var row in RemoteEntries)
{
row.IsChecked = wanted;
}
RaiseBatchState();
}
/// <inheritdoc cref="ToggleAllRemote" />
[RelayCommand]
private void ToggleAllLocal()
{
var wanted = CheckedLocalCount < LocalEntries.Count;
foreach (var row in LocalEntries)
{
row.IsChecked = wanted;
}
RaiseBatchState();
}
/// <summary>
/// Notices a row being ticked, so the counts and the buttons that read them keep up.
/// </summary>
/// <remarks>
/// Called by the views from the checkbox itself rather than subscribed per row. Each listing replaces
/// every row it has, so a per-row subscription would be one to attach and detach on every navigation —
/// and the thing being counted is a property of the collection rather than of any row, which is why the
/// collection's owner is what raises it.
/// </remarks>
internal void RemoteChecksChanged() => RaiseBatchState();
/// <inheritdoc cref="RemoteChecksChanged" />
internal void LocalChecksChanged() => RaiseBatchState();
/// <summary>Raises everything that reads the ticks, on both panes.</summary>
/// <remarks>
/// Both panes together, because the six properties are cheap to re-read and a caller that raised one
/// pane's would eventually be the caller that forgot the other's.
/// </remarks>
private void RaiseBatchState()
{
OnPropertyChanged(nameof(CheckedRemoteCount));
OnPropertyChanged(nameof(CheckedLocalCount));
OnPropertyChanged(nameof(HasCheckedRemote));
OnPropertyChanged(nameof(HasCheckedLocal));
OnPropertyChanged(nameof(CheckedRemoteLabel));
OnPropertyChanged(nameof(CheckedLocalLabel));
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanUpload));
RaiseItemActionState();
}
// ---- The local pane ----
[ObservableProperty]
private string localPath = LocalDirectory.Home;
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
/// <summary>
/// The drives this machine has, as somewhere the local pane can jump to.
/// </summary>
/// <remarks>
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
/// be unreachable from an application whose whole purpose on this screen is to move one.
/// </remarks>
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
[ObservableProperty]
private LocalEntryRowViewModel? selectedLocalEntry;
internal bool HasLocalEntries => LocalEntries.Count > 0;
// ---- The queue ----
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
/// <summary>
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
/// disconnects.
/// </summary>
/// <remarks>
/// For a head that has to tell the operating system what this process is doing — Android's foreground
/// service, which must be up for as long as bytes are moving, or a host session sits open, and down
/// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
/// collection announces rows arriving and leaving, and the transition that matters most is neither of
/// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
/// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
/// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
/// </remarks>
internal event EventHandler? ActivityChanged;
/// <summary>How many transfers are moving or waiting to move.</summary>
/// <remarks>
/// Queued counts as active. A queue with three files in it and one of them running is a process that
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
/// </remarks>
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
/// <summary>
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
/// </summary>
/// <remarks>
/// A string of its own rather than a converter in the markup, for the reason every other label in this
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
/// that shape rather than a chip claiming activity that has already finished.
/// </remarks>
internal string ActiveTransfersLabel => string.Create(
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
/// <remarks>
/// Any file in the batch is enough, rather than every one of them: a gathered-up set with a directory
/// among it is the ordinary case, and the queue counts and reports what it skipped — see
/// <see cref="QueueDownloads"/>. A button dead until the set was perfectly clean would be refusing to
/// start work it can plainly do most of.
/// </remarks>
internal bool CanDownload => IsConnected && RemoteBatch.Any(row => row.IsFile);
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
/// <inheritdoc cref="CanDownload" />
internal bool CanUpload => IsConnected && LocalBatch.Any(row => row.IsFile);
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
/// <param name="openVault">The open keychain.</param>
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
/// <param name="log">
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
/// when the keychain locks still ends somewhere.
/// </param>
internal void Attach(
VaultViewModel openVault,
VaultKnownHostStore hostKeys,
ConnectionRecorder? log = null,
IObjectStoreFactory? buckets = null)
{
vault = openVault;
knownHosts = hostKeys;
connectionLog = log;
objectStores = buckets;
// Followed rather than copied once, which is the difference between a picker that is right at unlock
// and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
// synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
// snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
// Detach: these collections belong to a vault that is about to be disposed.
openVault.Hosts.CollectionChanged += OnVaultListChanged;
openVault.ObjectStores.CollectionChanged += OnVaultListChanged;
RefreshHosts();
// Read once per unlock rather than per navigation: a drive appearing while the application is open
// is possible and rare, and probing every removable drive on every click into a folder is not.
LocalRoots.Clear();
foreach (var root in LocalDirectory.Roots())
{
LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
}
RefreshLocalCommand.Execute(null);
}
/// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
/// <remarks>
/// <para>
/// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
/// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
/// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
/// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
/// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
/// buttons past the window's edge at the session shell's 472-pixel budget.
/// </para>
/// <para>
/// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
/// application has ever typed into already means "my home directory" by it, which is exactly what the
/// chip does when pressed.
/// </para>
/// </remarks>
internal static string RootChipName(string root)
{
if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
{
return "~";
}
var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
if (trimmed.Length == 0)
{
// Unix's "/": trimming eats the whole string, and the root's name is the root itself.
return "/";
}
// A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
// no file-name segment at all, and the trimmed root is already the two-character name it always had.
var name = Path.GetFileName(trimmed);
return name.Length == 0 ? trimmed : name;
}
/// <summary>
/// Gives up the vault, keeping the connection and anything in flight.
/// </summary>
/// <remarks>
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
/// </remarks>
internal void Detach()
{
if (vault is { } open)
{
open.Hosts.CollectionChanged -= OnVaultListChanged;
open.ObjectStores.CollectionChanged -= OnVaultListChanged;
}
vault = null;
knownHosts = null;
Hosts.Clear();
Buckets.Clear();
SelectedHost = null;
SelectedBucket = null;
TypedPassword = string.Empty;
// The picker with it. Its two lists have just been emptied, so leaving it open would show a form
// offering a choice between nothing.
IsChoosingRemote = false;
}
// ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this
// screen offers is a destination now rather than a control on the screen — both heads reach it through
// MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first.
// Keeping the command would have left one nothing could invoke.
/// <summary>Opens the picker, from the invitation in the empty remote pane.</summary>
[RelayCommand]
private void BeginChoosingRemote() => IsChoosingRemote = true;
/// <summary>
/// Puts the picker away without connecting.
/// </summary>
/// <remarks>
/// The typed password goes with it. It is a secret nobody asked to keep, and leaving it in the box would
/// mean the next person to open the picker — for a different host, possibly — starts with somebody
/// else's password already typed in.
/// </remarks>
[RelayCommand]
private void CancelChoosingRemote()
{
IsChoosingRemote = false;
TypedPassword = string.Empty;
}
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
Remote is RemoteKind.Bucket
? OpenBucketAsync(cancellationToken)
: ConnectToHostAsync(cancellationToken);
/// <summary>
/// Opens the chosen bucket.
/// </summary>
/// <remarks>
/// <para>
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
/// why the failure this reports is a listing failure rather than a connection one — there is no
/// connection to fail.
/// </para>
/// <para>
/// It goes through the same session field, the same queue and the same panes as a host, because by this
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
/// SFTP and needed no change.
/// </para>
/// </remarks>
private async Task OpenBucketAsync(CancellationToken cancellationToken)
{
if (objectStores is not { } factory)
{
Status = "This build cannot open buckets.";
return;
}
if (SelectedBucket is not { } row)
{
Status = "Choose a bucket first.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Opening {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
session = factory.Open(row.Store);
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
// No SSH underneath a bucket, so none of the three has an honest value — see
// ConnectedCipher's own remark.
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
// And no pins either — see ConnectedPinnedPaths's own remark.
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
reconnectTo = (RemoteKind.Bucket, row.EntityId);
Watch(session);
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;
}
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
if (!open.TryBuildConnectionRequest(
row.Host, password, 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);
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
is not ({ } opened, { } dialled))
{
return;
}
session = opened;
TypedPassword = string.Empty;
MarkHostConnected(opened, dialled, row, identityLabel);
reconnectTo = (RemoteKind.Host, row.EntityId);
Watch(opened);
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
/// </summary>
/// <returns>
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
/// there is none.
/// </returns>
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
VaultViewModel open,
HostRowViewModel row,
SshConnectionRequest request,
CancellationToken cancellationToken)
{
IConnectionRoute? route = null;
try
{
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
// The session owns the route from here, on success and on failure alike.
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
var through = VaultViewModel.Through(request, route);
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
}
catch (Exception exception)
when (route?.Failure is { } failure
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
{
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
Status = failure;
return null;
}
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 null;
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. Nothing was connected.";
return null;
}
}
/// <summary>
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
/// disconnect log closes out later.
/// </summary>
/// <remarks>
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
/// as <c>IRemoteFileStore</c> and does not carry either.
/// </remarks>
private void MarkHostConnected(
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
{
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
ConnectedCipher = opened.Cipher;
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
ConnectedIdentityLabel = identityLabel;
// See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow.
ConnectedPinnedPaths.Clear();
foreach (var path in row.Host.PinnedPaths)
{
ConnectedPinnedPaths.Add(path);
}
OnPropertyChanged(nameof(HasConnectedPins));
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
// the host's — and anybody comparing the two would be right to believe the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
// Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
// and ConnectedCipher second — a partial method firing off the first assignment would read
// HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
// there. Only at the end of this method are both facts actually true together.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Listens for the store losing its connection on its own.</summary>
/// <remarks>
/// Nothing unsubscribes: the handler checks that the store raising it is still the one this screen holds,
/// and a store this screen has replaced is disposed — which stops it raising anything anyway. That check
/// is what a detach would buy, and having it means a session torn down mid-event cannot leave a
/// subscription pointing at a screen that has moved on.
/// </remarks>
private void Watch(IRemoteFileStore store) =>
store.ConnectionLost += (sender, _) => post(() => OnSessionConnectionLost(sender));
/// <summary>
/// Says the connection went away, and keeps everything a reconnect will need.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The listing stays on screen, and that is deliberate.</b> <see cref="SftpEntry"/> is a record of
/// what the server said rather than a handle on anything — its own remark says so — so the pane can go
/// on showing where the user was after the session behind it has gone. Clearing it would take away the
/// one thing that makes the reconnect below meaningful: they can see what they were doing, and the
/// button puts them back in it.
/// </para>
/// <para>
/// What does go is the session and the two open questions. A delete or a move left half-asked would
/// commit against a store that is not there, and both are about a path this screen can no longer promise
/// anything about.
/// </para>
/// <para>
/// Nothing is done about transfers in flight. The queue's own row carries the failure with the server's
/// words on it and keeps its part file, so a reconnect leaves RESUME meaning exactly what it meant
/// before — see <c>FileTransferQueue.Retry</c>. Reaching in to restart them here would be this screen
/// making a decision about somebody's file that they can make better themselves.
/// </para>
/// </remarks>
private void OnSessionConnectionLost(object? sender)
{
if (!ReferenceEquals(sender, session))
{
// A store this screen has already replaced or disposed. Its news is about a connection nobody is
// looking at any more.
return;
}
var reason = (session as ISftpSession)?.TransportFailure;
session = null;
IsConnected = false;
LostConnection = reason is { Length: > 0 } said ? said : "The connection was lost.";
PendingRemoteDeletion = null;
PendingRemoteMove = null;
PendingRemotePermissions = null;
RemoteMoveTarget = string.Empty;
// The ticket is closed here rather than left for the reconnect, because the session really did end
// at this moment and a log written later would say it lasted until somebody noticed.
CloseConnectionRecord(ConnectionOutcome.Closed);
RaiseRemotePaneState();
OnPropertyChanged(nameof(HasLiveFileSession));
Status = LostConnection;
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Opens the connection again, on the same remote, and goes back to the directory it was showing.
/// </summary>
/// <remarks>
/// <para>
/// It goes through <see cref="ConnectAsync"/> rather than round it, so the host key gate, the refusals
/// and the log entry are the same ones any other connection gets — a reconnect is a second login as far
/// as the host is concerned, and one that skipped the checks would be a second connect path to get
/// subtly wrong. What it adds either side is the two things that make it a <em>re</em>connect: the
/// remote is the one that was lost rather than whatever is lit, and the path is restored afterwards.
/// </para>
/// <para>
/// <b>A host that wants a password typed is asked for one rather than dialled without.</b> The password
/// is cleared the moment a connection succeeds and is not kept, so redialling with an empty box would be
/// an authentication failure wearing the word RECONNECT. The picker comes up with the host already
/// chosen, which is as far as this can honestly get on its own.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ReconnectAsync(CancellationToken cancellationToken)
{
if (reconnectTo is not { } target)
{
Status = "There is nothing here to connect to again.";
return;
}
// Read before anything below can clear it: connecting closes the old session first, and that is what
// empties the pane this path is being taken from.
var wasShowing = RemotePath;
Remote = target.Kind;
if (target.Kind is RemoteKind.Bucket)
{
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == target.EntityId);
if (SelectedBucket is null)
{
Status = "That bucket is not in this keychain any more.";
return;
}
}
else
{
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == target.EntityId);
if (SelectedHost is null)
{
Status = "That host is not in this keychain any more, so there is nothing here to dial again.";
return;
}
if (SelectedHostAsksForAPassword && TypedPassword.Length == 0)
{
IsChoosingRemote = true;
Status = $"{SelectedHost.Label} asks for a password. Type it here, then CONNECT.";
return;
}
}
await ConnectAsync(cancellationToken).ConfigureAwait(true);
if (!IsConnected)
{
// Whatever refused it has already said so through RunAsync's own status. The pane keeps the
// listing it had, so the button is still there to press again.
return;
}
if (wasShowing is { Length: > 0 }
&& !string.Equals(RemotePath, wasShowing, StringComparison.Ordinal))
{
// Back where they were, rather than in the home directory a fresh connect lands in. A failure
// here — the directory has gone, or is no longer readable — is reported by GoRemoteAsync and
// leaves the pane on the home directory, which is a place that exists.
await GoRemoteAsync(wasShowing, cancellationToken).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>
/// Goes wherever the path box says.
/// </summary>
/// <remarks>
/// <para>
/// The way out of the breadcrumb's own limit: it can only offer what is under the directory already
/// listed, so crossing the filesystem meant walking down to the root and back up again, a round trip a
/// level. What is typed may be absolute, relative to the directory showing, or start at <c>~</c> — see
/// <see cref="SftpPath.Resolve"/>, which is where all three become one absolute path.
/// </para>
/// <para>
/// A blank box is not a request to go to the root. It puts the current path back and stays where it is:
/// somebody who cleared the box and pressed Enter has not asked for anything, and <c>/</c> is a long way
/// from where they were.
/// </para>
/// <para>
/// Nothing is validated here beyond that. Whether the path exists, is a directory and may be read are
/// three questions only the server can answer, and it answers all three at once by refusing the listing
/// — which lands in the status line with the server's own words in it.
/// </para>
/// </remarks>
[RelayCommand]
private async Task GoToTypedRemotePathAsync(CancellationToken cancellationToken)
{
if (session is not { } open)
{
Status = "Connect to a host first.";
return;
}
if (RemotePathDraft.Trim().Length == 0)
{
RemotePathDraft = RemotePath;
return;
}
var from = RemotePath.Length > 0 ? RemotePath : open.HomeDirectory;
await NavigateRemoteAsync(
SftpPath.Resolve(from, RemotePathDraft, open.HomeDirectory), cancellationToken)
.ConfigureAwait(true);
}
/// <summary>Puts the real path back in the box, for the Escape key.</summary>
/// <remarks>
/// Half-typed text left in a box that says where you are is a box that is lying about where you are, and
/// the pane underneath it goes on showing the directory it always was. Escape is the way out of that
/// without having to remember what was in the box before.
/// </remarks>
[RelayCommand]
private void RevertTypedRemotePath() => RemotePathDraft = RemotePath;
/// <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>What a transfer asked for while another command is still running is told.</summary>
private const string StillBusy =
"Still working on the last request. Nothing was queued — try again when it has finished.";
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
[RelayCommand]
private void Download()
{
if (RemoteBatch is not { Count: > 0 } rows)
{
Status = "Choose a file on the host to download.";
return;
}
_ = RunAsync(
"Working out what to download…",
async () =>
{
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
ClearRemoteChecks();
},
StillBusy);
}
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
[RelayCommand]
private void Upload()
{
if (LocalBatch is not { Count: > 0 } rows)
{
Status = "Choose a file on this machine to upload.";
return;
}
var paths = rows.Select(row => row.FullPath).ToArray();
_ = RunAsync(
"Working out what to upload…",
async () =>
{
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
ClearLocalChecks();
},
StillBusy);
}
/// <summary>Unticks everything on the host's side.</summary>
/// <remarks>
/// Run after a batch has been queued, and after a listing replaces the rows. A gathered-up set is about
/// one act — these files, into that directory — and leaving the boxes ticked afterwards is how the next
/// press sends them all a second time.
/// </remarks>
private void ClearRemoteChecks()
{
foreach (var row in RemoteEntries)
{
row.IsChecked = false;
}
RaiseBatchState();
}
/// <inheritdoc cref="ClearRemoteChecks" />
private void ClearLocalChecks()
{
foreach (var row in LocalEntries)
{
row.IsChecked = false;
}
RaiseBatchState();
}
/// <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>A directory is walked rather than skipped, which it used to be.</b> The queue still moves one
/// file at a time and knows nothing about trees — what happens here is that the tree is turned into
/// files before it gets there, and the directories it needs are made on the host first. That keeps
/// every property the queue has: each file is resumable on its own, each is written to a part file and
/// renamed into place, and none of them can overwrite something already there.
/// </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>
/// <summary>
/// Starts an upload walk from something a drop handed over, and reports it through the busy funnel.
/// </summary>
/// <remarks>
/// ◆ <b>A separate entry point because a drop cannot await.</b> The walk became asynchronous when
/// folders stopped being skipped — it lists directories over the wire and makes them on the host — and
/// an Avalonia drop handler that awaited it would be an <c>async void</c> whose failures nothing
/// catches. <see cref="RunAsync"/> is where every other command on this screen puts exactly that
/// problem: it owns the busy flag and turns a failure into a status line.
/// </remarks>
internal void QueueUploads(IReadOnlyList<string> paths) =>
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
/// <inheritdoc cref="QueueUploads" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
_ = RunAsync(
"Working out what to download…",
() => QueueDownloadsAsync(rows, CancellationToken.None),
StillBusy);
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(paths);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var plan = new TransferPlan();
foreach (var path in paths)
{
if (Directory.Exists(path))
{
WalkLocal(path, SftpPath.Combine(RemotePath, Path.GetFileName(path)), plan);
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))
{
plan.Missing++;
continue;
}
plan.Files.Add((path, SftpPath.Combine(RemotePath, Path.GetFileName(path))));
}
// ◆ Before anything is queued, and in shortest-first order so a parent exists before its child is
// asked for. A transfer whose directory is not there fails at the part file and leaves a row saying
// something about a path rather than about the folder that was dragged.
foreach (var directory in plan.Directories.OrderBy(each => each.Length))
{
if (!await EnsureRemoteDirectoryAsync(directory, cancellationToken).ConfigureAwait(true))
{
plan.Refused++;
}
}
foreach (var (local, remote) in plan.Files)
{
var file = new FileInfo(local);
queue.Enqueue(TransferDirection.Upload, local, remote, file.Length, Preserved(file));
plan.Queued++;
}
Status = Describe(plan, "upload into", RemotePath);
}
/// <summary>
/// Adds every file under a local directory to the plan, with the remote path each will land at.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Reparse points are not followed, and that is a correctness rule rather than a nicety.</b> A
/// junction or a symlink pointing at an ancestor is a walk that never ends, and the first anybody would
/// know of it is a queue filling for ever. <see cref="EnumerationOptions.AttributesToSkip"/> is what
/// rules it out, at the one place a decision is made about what to descend into.
/// </para>
/// <para>
/// <b>Empty directories are carried too.</b> They are in the plan's directory set even when no file
/// under them reaches the file list, because a build output tree with an empty <c>logs/</c> in it is a
/// tree that arrives wrong if the folder is silently dropped.
/// </para>
/// </remarks>
private static void WalkLocal(string root, string destination, TransferPlan plan)
{
plan.Directories.Add(destination);
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.ReparsePoint,
IgnoreInaccessible = true,
};
foreach (var directory in Directory.EnumerateDirectories(root, "*", options))
{
plan.Directories.Add(RemoteFor(root, destination, directory));
}
foreach (var file in Directory.EnumerateFiles(root, "*", options))
{
if (plan.IsFull)
{
plan.Truncated = true;
return;
}
plan.Files.Add((file, RemoteFor(root, destination, file)));
}
}
/// <summary>Where a path under <paramref name="root"/> lands under <paramref name="destination"/>.</summary>
/// <remarks>
/// Split on both separators rather than on <see cref="Path.DirectorySeparatorChar"/>, and rejoined with
/// <c>/</c> through <see cref="SftpPath"/>: a remote path is POSIX whatever this client is running on,
/// and a Windows relative path carrying backslashes into <c>SftpPath.Combine</c> would produce
/// <c>/srv/app\assets\main.js</c> — one file with a backslash in its name rather than three
/// directories deep.
/// </remarks>
private static string RemoteFor(string root, string destination, string path)
{
var relative = Path.GetRelativePath(root, path);
var walked = destination;
foreach (var segment in relative.Split(
['/', '\\'], StringSplitOptions.RemoveEmptyEntries))
{
walked = SftpPath.Combine(walked, segment);
}
return walked;
}
/// <summary>Makes one remote directory if it is not already there.</summary>
/// <returns>Whether it exists now.</returns>
/// <remarks>
/// Asked about before it is made rather than made and the failure swallowed. The two are not the same:
/// a directory that is already there is the ordinary case of uploading into a tree that partly exists,
/// and a <c>mkdir</c> refused for any other reason — no permission, a file of that name in the way — is
/// a thing the person who dragged the folder needs told about.
/// </remarks>
private async Task<bool> EnsureRemoteDirectoryAsync(string path, CancellationToken cancellationToken)
{
var open = RequireSession();
try
{
if (await open.StatAsync(path, cancellationToken).ConfigureAwait(true) is not null)
{
return true;
}
await open.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(true);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
return false;
}
}
/// <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);
}
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
}
/// <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="QueueUploadsAsync" path="/remarks" />
internal async Task QueueDownloadsAsync(
IReadOnlyList<RemoteEntryRowViewModel> rows,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(rows);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var plan = new TransferPlan();
foreach (var row in rows)
{
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
{
plan.Unsafe++;
continue;
}
if (row.IsFile)
{
plan.Downloads.Add((local, row.Entry));
continue;
}
if (row.Entry.Kind is SftpEntryKind.SymbolicLink)
{
// Not followed, for the reason WalkLocal skips a reparse point — and here there is a second
// one: an lstat listing says a name is a link and does not say what it leads to, so
// descending would mean a round trip per entry to find out, and a link pointing at an
// ancestor is a walk with no end.
plan.Links++;
continue;
}
await WalkRemoteAsync(
row.FullPath,
local,
plan,
cancellationToken).ConfigureAwait(true);
}
EnqueueDownloads(plan);
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
private void EnqueueDownloads(TransferPlan plan)
{
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
plan.Refused++;
}
}
foreach (var (local, entry) in plan.Downloads)
{
queue.Enqueue(
TransferDirection.Download, local, entry.FullPath, entry.Length, Preserved(entry));
plan.Queued++;
}
}
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
/// <remarks>
/// <para>
/// Breadth-first over <c>ListAsync</c>, which is a round trip per directory and nothing cleverer: SFTP
/// has no recursive listing, so the tree has to be walked whatever this does. What it must not do is walk
/// for ever — see the link rule below and <see cref="TransferPlan.IsFull"/>.
/// </para>
/// <para>
/// A directory that cannot be listed is counted and the walk carries on. Half a tree with a sentence
/// saying which part was refused is worth more than nothing with the same sentence, and a permission
/// somewhere deep is the ordinary reason a whole-folder download of somebody else's tree stops short.
/// </para>
/// </remarks>
private async Task WalkRemoteAsync(
string remote,
string local,
TransferPlan plan,
CancellationToken cancellationToken)
{
var pending = new Queue<(string Remote, string Local)>();
pending.Enqueue((remote, local));
while (pending.Count > 0)
{
var (fromDirectory, toDirectory) = pending.Dequeue();
plan.LocalDirectories.Add(toDirectory);
IReadOnlyList<SftpEntry> entries;
try
{
entries = await RequireSession()
.ListAsync(fromDirectory, cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
plan.Refused++;
continue;
}
foreach (var entry in entries)
{
if (plan.IsFull)
{
plan.Truncated = true;
return;
}
PlanRemoteEntry(plan, pending, entry, toDirectory);
}
}
}
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
private static void PlanRemoteEntry(
TransferPlan plan,
Queue<(string Remote, string Local)> pending,
SftpEntry entry,
string toDirectory)
{
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
? LocalDirectory.ChildPath(toDirectory, entry.Name)
: null;
switch (entry.Kind)
{
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
plan.Unsafe++;
break;
case SftpEntryKind.File:
plan.Downloads.Add((here!, entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, here!));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
// counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
}
/// <summary>What an upload of this local file should carry to the host with it.</summary>
/// <remarks>
/// <para>
/// ◆ <b>The mode is only sent from a platform that has one.</b> A file on Windows has no POSIX mode —
/// this screen already says so by leaving the local pane's <c>PERMS</c> column empty rather than filling
/// it with a plausible <c>-rw-r--r--</c> — so an upload from there carries the timestamp alone and lands
/// with whatever the server's umask gives it. Inventing 0644, or 0755 for anything called <c>.sh</c>,
/// would be this client deciding a fact about somebody's file that nothing on the machine it came from
/// ever said.
/// </para>
/// <para>
/// A bucket carries nothing: it has neither to set, and asking would be a refusal per transfer. See
/// <c>S3FileStore</c>.
/// </para>
/// </remarks>
private TransferAttributes? Preserved(FileInfo file)
{
if (Remote is not RemoteKind.Host)
{
return null;
}
int? mode = null;
if (!OperatingSystem.IsWindows())
{
mode = PosixMode.Sanitise((int)file.UnixFileMode);
}
// Stated as UTC rather than left to an implicit conversion, which would read the local machine's
// offset off a DateTime that already is UTC and move the timestamp by it.
return new TransferAttributes(
mode, new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero));
}
/// <summary>What a download of this remote entry should carry to this machine with it.</summary>
/// <remarks>
/// The other direction of <see cref="Preserved(FileInfo)"/>, and the mode is passed whatever this
/// platform is: the queue is what knows whether the local filesystem can be told one, and it drops the
/// mode rather than failing where it cannot. See <c>FileTransferQueue.ApplyLocally</c>.
/// </remarks>
private TransferAttributes? Preserved(SftpEntry entry) =>
Remote is RemoteKind.Host
? new TransferAttributes(PosixMode.ToOctal(entry.Permissions), entry.LastWriteTimeUtc)
: null;
/// <summary>
/// What a walk found, before any of it is handed to the queue.
/// </summary>
/// <remarks>
/// ◆ <b>Collected whole and then queued, rather than queued as it is found.</b> The directories have to
/// exist before the files that go in them, and shortest-first is the only order that guarantees a
/// parent before its child — which needs the whole set before the first one is made. It also means a
/// walk that is refused halfway has queued nothing yet, so the sentence it produces describes one
/// outcome rather than an outcome and a queue that half happened.
/// </remarks>
private sealed class TransferPlan
{
/// <summary>
/// The most files one walk will collect.
/// </summary>
/// <remarks>
/// A backstop rather than a policy, and it is deliberately far above any tree somebody means to
/// drag: what it exists for is the accident — a home directory, a mounted volume, the root — where
/// the honest answer is to stop and say so rather than to fill a queue for a quarter of an hour.
/// </remarks>
private const int Ceiling = 5000;
/// <summary>Local path and its destination on the host, for an upload.</summary>
public List<(string Local, string Remote)> Files { get; } = [];
/// <summary>Where it will land here, and what the listing said it is, for a download.</summary>
public List<(string Local, SftpEntry Entry)> Downloads { get; } = [];
/// <summary>Directories to make on the host before the uploads go.</summary>
public HashSet<string> Directories { get; } = new(StringComparer.Ordinal);
/// <summary>Directories to make here before the downloads land.</summary>
public HashSet<string> LocalDirectories { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many were actually handed to the queue.</summary>
public int Queued { get; set; }
/// <summary>Paths that were gone by the time the walk reached them.</summary>
public int Missing { get; set; }
/// <summary>Directories the host or this machine would not let the walk have.</summary>
public int Refused { get; set; }
/// <summary>Symbolic links, which are counted rather than followed.</summary>
public int Links { get; set; }
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
public int Skipped { get; set; }
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
public int Unsafe { get; set; }
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
public bool Truncated { get; set; }
/// <summary>Whether the ceiling has been reached.</summary>
public bool IsFull => Files.Count + Downloads.Count >= Ceiling;
}
/// <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 — and with folders walked rather than skipped,
/// there are four more ways for a set to be partly what was asked for.
/// </remarks>
private static string Describe(TransferPlan plan, string verb, string destination)
{
var files = plan.Queued == 1 ? "1 file" : $"{plan.Queued} files";
var said = plan.Queued == 0
? "Nothing was queued."
: $"Queued {files} for {verb} {destination}.";
if (plan.Missing > 0)
{
var gone = plan.Missing == 1 ? "1 item was" : $"{plan.Missing} items were";
said += $" {gone} no longer there.";
}
if (plan.Refused > 0)
{
var refused = plan.Refused == 1 ? "1 directory" : $"{plan.Refused} directories";
said += $" {refused} could not be read or made.";
}
if (plan.Links > 0)
{
var links = plan.Links == 1 ? "1 link was" : $"{plan.Links} links were";
said += $" {links} left alone — a link is not followed.";
}
if (plan.Skipped > 0)
{
var other = plan.Skipped == 1 ? "1 entry was" : $"{plan.Skipped} entries were";
said += $" {other} not a file.";
}
if (plan.Unsafe > 0)
{
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
said += $" {named} left on the host.";
}
if (plan.Truncated)
{
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
}
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 (RemoteBatch is not { Count: > 0 } rows)
{
Status = "Choose something on the host to delete.";
return;
}
// The others go, so no two forms can be on screen at once over one row.
PendingRemoteMove = null;
PendingRemotePermissions = null;
PendingRemoteDeletion = new RemoteDeletionRequest(
[.. rows.Select(row => new RemoteDeletionTarget(row.Name, row.FullPath, !row.IsFile))]);
}
/// <summary>
/// Opens the mode of the chosen row for editing.
/// </summary>
/// <remarks>
/// ◆ <b>The <c>PERMS</c> column stops being read-only here.</b> It has been a true fact about a file
/// with nothing to be done about it since it arrived, and the commonest next act after using this screen
/// is opening a terminal to make the thing just uploaded executable — which is a whole second
/// authenticated connection to set nine bits this one is already holding a session for.
/// <para>
/// It starts from the mode the listing has rather than from a fresh stat. That listing is what the user
/// is looking at and what they are about to change; a re-read would be a round trip to fetch a number
/// already on screen, and a form that opened showing something different from the column beside it
/// would be the worse of the two ways to be out of date.
/// </para>
/// </remarks>
[RelayCommand]
private void ChangeRemotePermissions()
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose something on the host to change the permissions of.";
return;
}
if (Remote is not RemoteKind.Host)
{
Status = "An object in a bucket has an access policy rather than a POSIX mode.";
return;
}
PendingRemoteDeletion = null;
PendingRemoteMove = null;
PendingRemotePermissions = new RemotePermissionsRequest(
row.Name, row.FullPath, !row.IsFile, row.Permissions);
}
/// <summary>
/// Sends the mode that was ticked.
/// </summary>
/// <remarks>
/// It acts on the path the form named rather than on the selection, which is the promise the other two
/// questions make and rests on the same reasoning: nothing between opening the form and pressing its
/// button may re-point it.
/// </remarks>
[RelayCommand]
private async Task ConfirmRemotePermissionsAsync(CancellationToken cancellationToken)
{
if (PendingRemotePermissions is not { } request)
{
return;
}
var mode = request.Mode;
await RunAsync(
$"Setting permissions on {request.Name}…",
async () =>
{
await RequireSession()
.SetPermissionsAsync(request.FullPath, mode, cancellationToken)
.ConfigureAwait(true);
PendingRemotePermissions = null;
// Re-read, because the column beside the row has just stopped being true — and the server is
// the only thing that knows what it actually settled on. A umask, an ACL or a filesystem
// mounted without permissions can all land somewhere other than what was asked for.
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"{request.Name} is now {PosixMode.FormatOctal(mode)}.";
}).ConfigureAwait(true);
}
/// <summary>Puts the mode form away without sending anything.</summary>
[RelayCommand]
private void CancelRemotePermissions() => PendingRemotePermissions = null;
/// <summary>
/// Fetches the chosen file, opens it in this machine's editor, and writes each save back.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Not through the transfer queue, in either direction, and that is deliberate.</b> The queue
/// refuses to overwrite — which is the right rule for a file somebody dragged and exactly the wrong one
/// for a file they are editing, where replacing the original is the entire intent. So this reads and
/// writes the bytes itself. It is a config file: small, and worth neither a part file nor a resume.
/// </para>
/// <para>
/// <b>The copy is this screen's, and it is deleted when the session ends.</b> It goes beside the ones
/// the phone's document staging already makes, under a name derived from the remote path so that two
/// files called <c>config</c> from different directories do not land on each other.
/// </para>
/// </remarks>
[RelayCommand]
private async Task EditRemoteAsync(CancellationToken cancellationToken)
{
if (openInEditor is not { } open || watchFile is not { } watch)
{
Status = "This build cannot open a file in an editor.";
return;
}
if (SelectedRemoteEntry is not { IsFile: true } row)
{
Status = "Choose a file on the host to edit.";
return;
}
await StopEditingAsync().ConfigureAwait(true);
await RunAsync(
$"Fetching {row.Name}…",
async () =>
{
var folder = Path.Combine(
Path.GetTempPath(),
"dodossh-edits",
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
{
Status = $"{row.Name} is not a name this machine can open as a file.";
return;
}
Directory.CreateDirectory(folder);
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
var editing = new RemoteEditSession(
row.Name, row.FullPath, copy, PosixMode.ToOctal(row.Permissions))
{
Stamp = (row.Entry.Length, row.Entry.LastWriteTimeUtc),
};
RemoteEdit = editing;
RemoteEditConflict = null;
RemoteEditLog.Clear();
Note("opened, watching for saves");
// Watched before it is opened, so a save made the instant the editor comes up is not the one
// that gets missed.
editWatch = watch(copy, () => post(() => _ = SaveEditAsync(editing, force: false)));
await open(copy).ConfigureAwait(true);
Status = $"{row.Name} is open. Saves go back to the host.";
}).ConfigureAwait(true);
}
/// <summary>Writes the edited copy back, unless the host's own has moved since it was fetched.</summary>
/// <remarks>
/// <para>
/// The check is a stat and a comparison of length and timestamp against what the listing said when the
/// copy was taken — see <see cref="RemoteEditSession.Stamp"/> for why those two and not a digest. It is
/// made before every save rather than once, because the whole risk this guards against is somebody else
/// writing while this file is open.
/// </para>
/// <para>
/// <paramref name="force"/> is the answer to that question and nothing else: it is set only by the
/// button that says UPLOAD ANYWAY, over a strip that has just said what changed.
/// </para>
/// </remarks>
private async Task SaveEditAsync(RemoteEditSession session, bool force)
{
if (!ReferenceEquals(session, RemoteEdit) || !IsConnected)
{
// A save arriving for a session that has been stopped, or after the connection went. The copy
// is still on disk either way; what is gone is the right to write it back.
return;
}
try
{
var open = RequireSession();
if (!force)
{
var current = await open.StatAsync(session.RemotePath, CancellationToken.None)
.ConfigureAwait(true);
if (current is null)
{
RemoteEditConflict = "It is no longer on the host.";
Note("not saved — the file is gone");
return;
}
if (current.Length != session.Stamp.Length
|| current.LastWriteTimeUtc != session.Stamp.Written)
{
RemoteEditConflict = "The server's copy has changed since you opened it.";
Note("not saved — it changed on the host");
return;
}
}
var bytes = await File.ReadAllBytesAsync(session.LocalCopy, CancellationToken.None)
.ConfigureAwait(true);
await WriteBackAsync(open, session, bytes).ConfigureAwait(true);
// The stamp moves to what this write produced — in place, on the session the watcher is holding.
// See RemoteEditSession.Stamp.
if (await open.StatAsync(session.RemotePath, CancellationToken.None).ConfigureAwait(true)
is { } written)
{
session.Stamp = (written.Length, written.LastWriteTimeUtc);
}
RemoteEditConflict = null;
Note($"saved → uploaded ({ByteSize.Format(bytes.Length)})");
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
Note($"not saved — {exception.Message}");
}
}
/// <summary>Replaces the file on the host with the bytes that were just saved here.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Truncated first, and that is not optional.</b> <c>OpenWriteAsync</c> at offset zero writes over
/// the beginning and leaves whatever tail is longer than the new content — its own contract says so —
/// which for a config that got shorter means a file ending in the back half of its previous version.
/// Deleting is the only truncation this interface offers.
/// </para>
/// <para>
/// The mode goes back on afterwards, because deleting took it with it. Best-effort: a server that will
/// not set one has still received the bytes, and failing the save over the nine bits would be the wrong
/// half to insist on.
/// </para>
/// </remarks>
private static async Task WriteBackAsync(
IRemoteFileStore store, RemoteEditSession session, byte[] bytes)
{
await store.DeleteAsync(session.RemotePath, CancellationToken.None).ConfigureAwait(true);
var writing = await store.OpenWriteAsync(session.RemotePath, 0, CancellationToken.None)
.ConfigureAwait(true);
await using (writing.ConfigureAwait(false))
{
await writing.WriteAsync(bytes, CancellationToken.None).ConfigureAwait(true);
}
if (session.Mode == 0)
{
return;
}
try
{
await store.SetPermissionsAsync(session.RemotePath, session.Mode, CancellationToken.None)
.ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
// The bytes are there. See the remark above.
}
}
/// <summary>Writes the save the conflict card asked about, over whatever is on the host now.</summary>
[RelayCommand]
private async Task ForceSaveEditAsync()
{
if (RemoteEdit is { } session)
{
await SaveEditAsync(session, force: true).ConfigureAwait(true);
}
}
/// <summary>Fetches the host's copy again, over the local one the editor has open.</summary>
/// <remarks>
/// The other honest answer to a conflict: throw away what was typed and start from what is there. It
/// keeps the same temporary file so the editor does not need reopening — every editor worth the name
/// notices its file changing underneath it and offers to reload.
/// </remarks>
[RelayCommand]
private async Task ReloadEditAsync(CancellationToken cancellationToken)
{
if (RemoteEdit is not { } session)
{
return;
}
await RunAsync(
$"Fetching {session.Name} again…",
async () =>
{
await FetchAsync(session.RemotePath, session.LocalCopy, cancellationToken)
.ConfigureAwait(true);
if (await RequireSession().StatAsync(session.RemotePath, cancellationToken)
.ConfigureAwait(true) is { } current)
{
session.Stamp = (current.Length, current.LastWriteTimeUtc);
}
RemoteEditConflict = null;
Note("reloaded from the host");
}).ConfigureAwait(true);
}
/// <summary>Stops watching, and removes the copy this screen made.</summary>
[RelayCommand]
private Task StopEditingAsync()
{
editWatch?.Dispose();
editWatch = null;
if (RemoteEdit is { } session)
{
RemoveEditCopy(session);
}
RemoteEdit = null;
RemoteEditConflict = null;
RemoteEditLog.Clear();
return Task.CompletedTask;
}
/// <summary>Reads a remote file into a local path, replacing whatever is there.</summary>
private async Task FetchAsync(string remotePath, string localPath, CancellationToken cancellationToken)
{
var reading = await RequireSession()
.OpenReadAsync(remotePath, 0, cancellationToken)
.ConfigureAwait(true);
await using var readingScope = reading.ConfigureAwait(false);
var writing = new FileStream(
localPath, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true);
await using var writingScope = writing.ConfigureAwait(false);
await reading.CopyToAsync(writing, cancellationToken).ConfigureAwait(true);
}
/// <summary>Removes the temporary copy and the directory made to hold it.</summary>
/// <remarks>
/// Best-effort, and quietly: the editor may still have the file open, which on Windows means the delete
/// fails — and a screen that reported that would be reporting on a file the user never asked to exist.
/// What is left behind is one small file in the temporary directory the operating system clears.
/// </remarks>
private static void RemoveEditCopy(RemoteEditSession session)
{
try
{
File.Delete(session.LocalCopy);
if (Path.GetDirectoryName(session.LocalCopy) is { Length: > 0 } folder)
{
Directory.Delete(folder);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
}
}
/// <summary>Adds a line to the edit strip's log, newest first and bounded.</summary>
private void Note(string what)
{
RemoteEditLog.Insert(0, what);
while (RemoteEditLog.Count > EditLogDepth)
{
RemoteEditLog.RemoveAt(RemoteEditLog.Count - 1);
}
}
/// <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(
request.IsSingle ? $"Deleting {request.First.Name}…" : $"Deleting {request.Count} items…",
async () =>
{
var open = RequireSession();
var deleted = 0;
var refusals = new List<string>();
foreach (var target in request.Paths)
{
// ◆ Each on its own, and a refusal does not stop the rest. The host turns down a
// directory that still has something in it — which is the refusal this screen depends on
// rather than checking for — and in a set of five that must not silently cancel the four
// it would have removed. What it must also not do is hide which ones were refused.
try
{
await open.DeleteAsync(target.FullPath, cancellationToken).ConfigureAwait(true);
deleted++;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
refusals.Add($"{target.Name}: {exception.Message}");
}
}
ClearRemoteChecks();
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = Deleted(deleted, refusals);
}).ConfigureAwait(true);
}
/// <summary>What a batch delete has to say for itself, however mixed the outcome was.</summary>
/// <remarks>
/// One sentence for every shape of it, and what it must never do is stay quiet about the difference: a
/// set of five that removed three and reported "deleted 3" leaves somebody looking for two files that
/// are still there, with nothing saying why.
/// </remarks>
private static string Deleted(int deleted, List<string> refusals)
{
var said = deleted switch
{
0 => "Nothing was deleted.",
1 => "Deleted 1 item.",
var many => $"Deleted {many} items.",
};
return refusals.Count == 0 ? said : $"{said} {string.Join(" ", refusals)}";
}
/// <summary>Thinks better of it.</summary>
/// <remarks>
/// Settles the listing on the way out. A transfer that landed while the question was up left the pane
/// out of date and was not allowed to refresh it — see <see cref="SettleRemoteListingAsync"/> — so
/// dismissing the question is the moment that refresh becomes safe.
/// </remarks>
[RelayCommand]
private async Task CancelDeleteRemoteAsync()
{
PendingRemoteDeletion = null;
await SettleRemoteListingAsync().ConfigureAwait(true);
}
/// <summary>Opens the rename form on the chosen row.</summary>
[RelayCommand]
private void RenameRemote() => AskAboutMoving(RemoteMoveKind.Rename);
/// <summary>Opens the move form on the chosen row.</summary>
[RelayCommand]
private void MoveRemote() => AskAboutMoving(RemoteMoveKind.Move);
/// <remarks>
/// Both forms, because they differ only in what the box starts out holding and what the labels say —
/// see <see cref="RemoteMoveRequest"/>. The rename starts from the current name and the move from the
/// current directory, so each opens showing the answer that changes nothing and what gets edited is the
/// half somebody actually came to change.
/// </remarks>
private void AskAboutMoving(RemoteMoveKind kind)
{
if (SelectedRemoteEntry is not { } row)
{
Status = kind is RemoteMoveKind.Rename
? "Choose something on the host to rename."
: "Choose something on the host to move.";
return;
}
// The others go, so no two forms can be on screen at once over one row.
PendingRemoteDeletion = null;
PendingRemotePermissions = null;
PendingRemoteMove = new RemoteMoveRequest(row.Name, row.FullPath, !row.IsFile, kind);
RemoteMoveTarget = kind is RemoteMoveKind.Rename
? row.Name
: SftpPath.Parent(row.FullPath);
}
/// <summary>
/// Carries out whichever of the two was filled in.
/// </summary>
/// <remarks>
/// <para>
/// One SFTP rename underneath both, which is what the protocol offers and the whole of what a move is
/// on one host. It follows that neither works across hosts and neither works across a filesystem
/// boundary on the same host: the server refuses with <c>EXDEV</c>, and that refusal arrives in the
/// status line as the server's own sentence rather than being pre-empted by a guess here about which of
/// its mounts a path is on.
/// </para>
/// <para>
/// It acts on the path the form named rather than on the selection, for the reason
/// <see cref="ConfirmDeleteRemoteAsync"/> does: nothing between opening the form and pressing its button
/// may re-point it at a different file.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveRemoteAsync(CancellationToken cancellationToken)
{
if (PendingRemoteMove is not { } request)
{
return;
}
if (!TryResolveMoveDestination(request, out var destination, out var refusal))
{
// The form stays open. What was typed is what has to be corrected, and taking the form away
// would take the box holding it as well.
Status = refusal;
return;
}
PendingRemoteMove = null;
await RunAsync(
request.IsRename ? $"Renaming {request.Name}…" : $"Moving {request.Name}…",
async () =>
{
await RequireSession()
.RenameAsync(request.FullPath, destination, cancellationToken)
.ConfigureAwait(true);
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = request.IsRename
? $"Renamed {request.Name} to {SftpPath.Name(destination)}."
: $"Moved {request.Name} to {SftpPath.Parent(destination)}.";
}).ConfigureAwait(true);
}
/// <summary>Thinks better of it.</summary>
/// <inheritdoc cref="CancelDeleteRemoteAsync" path="/remarks" />
[RelayCommand]
private async Task CancelMoveRemoteAsync()
{
PendingRemoteMove = null;
RemoteMoveTarget = string.Empty;
await SettleRemoteListingAsync().ConfigureAwait(true);
}
/// <summary>
/// Works out where the typed answer actually points, or why it points nowhere.
/// </summary>
/// <remarks>
/// The three refusals here are the ones a server's own error message would not explain. A separator in
/// a name is a move wearing a rename's clothing and the server would simply say "no such file" about a
/// directory nobody meant to name; a destination equal to the source is a request that succeeds while
/// doing nothing; and a directory moved inside itself is <c>EINVAL</c>, which is true and says nothing.
/// Everything else — a path that is not there, a name already taken, a mount that cannot be crossed —
/// is left to the server, which knows and this does not.
/// </remarks>
private bool TryResolveMoveDestination(
RemoteMoveRequest request, out string destination, out string refusal)
{
destination = string.Empty;
var typed = RemoteMoveTarget.Trim();
if (typed.Length == 0)
{
refusal = request.IsRename
? "Type a new name for it."
: "Type the directory to move it into.";
return false;
}
if (request.IsRename)
{
if (typed.Contains('/', StringComparison.Ordinal))
{
refusal = "A name cannot contain '/'. Use MOVE to put it in another directory.";
return false;
}
destination = SftpPath.Combine(SftpPath.Parent(request.FullPath), typed);
}
else
{
var into = SftpPath.Resolve(RemotePath, typed, session?.HomeDirectory);
destination = SftpPath.Combine(into, request.Name);
}
if (string.Equals(destination, request.FullPath, StringComparison.Ordinal))
{
refusal = $"{request.Name} is already there.";
return false;
}
if (request.IsDirectory
&& destination.StartsWith(request.FullPath + "/", StringComparison.Ordinal))
{
refusal = "A directory cannot be moved inside itself.";
return false;
}
refusal = string.Empty;
return true;
}
/// <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));
}
/// <remarks>
/// <para>
/// <b>The selection survives if the row it was on does.</b> Every listing replaces every row object, so
/// keeping the object would keep a selection pointing at something no longer in the list; it is re-found
/// by path instead. This matters because a listing is no longer only something somebody asked for — a
/// finished upload refreshes this pane on its own, and a refresh that cleared the selection would take
/// the row out from under whoever was about to press RENAME on it.
/// </para>
/// <para>
/// The path box is written back here rather than by each caller, so that every way of moving — a crumb,
/// UP, a pin, connecting, a rename that lands — leaves the box saying where the pane actually is.
/// </para>
/// </remarks>
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
{
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
var wasSelected = SelectedRemoteEntry?.FullPath;
RemotePath = path;
RemotePathDraft = path;
SelectedRemoteEntry = null;
// Whatever made it stale, this is a fresh read of the very directory that was stale.
remoteListingIsStale = false;
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));
}
SelectedRemoteEntry = wasSelected is null
? null
: RemoteEntries.FirstOrDefault(
row => string.Equals(row.FullPath, wasSelected, StringComparison.Ordinal));
Status = string.Empty;
}
/// <summary>
/// Re-reads the remote directory because something landed in it, if now is a moment to.
/// </summary>
/// <remarks>
/// <para>
/// <b>An upload that finished is a listing that is wrong.</b> The pane went on showing the directory as
/// it was before the file arrived, and the only way to see the file that had just been sent was to press
/// REFRESH — which is the application asking somebody to tell it about something it did itself.
/// </para>
/// <para>
/// <b>It waits rather than interrupting.</b> Three states are not the moment: another operation is
/// running, which would have two listings racing for the same collection; one of the three questions is
/// on screen, and pulling the listing out from under a question about a row is how the row somebody
/// answered about stops being the row that was acted on; or nothing is connected. In all three the
/// staleness is remembered and settled later — at the end of the operation, or when the question is
/// dismissed.
/// </para>
/// <para>
/// <b>It says nothing.</b> Nobody asked for this listing, so it neither announces itself nor reports a
/// failure: the status line at that moment is carrying the word about the transfer that just finished,
/// which is the news. A refresh that failed leaves the pane as it was and the staleness set, so the next
/// settling tries again.
/// </para>
/// </remarks>
internal async Task SettleRemoteListingAsync()
{
if (!remoteListingIsStale || !IsConnected || IsBusy || IsAskingAboutRemoteEntry
|| RemotePath.Length == 0)
{
return;
}
var said = Status;
IsBusy = true;
try
{
await ListRemoteAsync(RemotePath, CancellationToken.None).ConfigureAwait(true);
Status = said;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
remoteListingIsStale = true;
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Notices that a finished transfer changed a directory that is on screen.
/// </summary>
/// <remarks>
/// Both panes, because both go stale the same way and for the same reason — an upload lands on the host
/// and a download lands on this machine, and whichever pane is showing that directory is now showing it
/// as it was a moment ago. Only the directory actually on screen: a transfer into somewhere else changed
/// nothing anybody is looking at, and re-listing for it would be a round trip per completed transfer in
/// a queue that may be moving a hundred files elsewhere.
/// </remarks>
internal void NoteTransferLanded(TransferSnapshot transfer)
{
if (transfer.Direction is TransferDirection.Upload)
{
if (string.Equals(SftpPath.Parent(transfer.RemotePath), RemotePath, StringComparison.Ordinal))
{
remoteListingIsStale = true;
_ = SettleRemoteListingAsync();
}
return;
}
// The local pane is a filesystem call rather than a round trip, so there is nothing to defer: it is
// re-read where it stands. Compared case-insensitively because this end is Windows as often as not.
if (string.Equals(
Path.GetDirectoryName(transfer.LocalPath), LocalPath, StringComparison.OrdinalIgnoreCase))
{
NavigateLocal(LocalPath);
}
}
/// <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);
var wasSelected = SelectedLocalEntry?.FullPath;
LocalPath = Path.GetFullPath(path);
SelectedLocalEntry = null;
LocalEntries.Clear();
foreach (var entry in entries)
{
LocalEntries.Add(new LocalEntryRowViewModel(entry));
}
RebuildLocalTrail();
// Re-found by path rather than kept, for the reason ListRemoteAsync gives: a finished download
// re-reads this pane on its own, and a refresh that cleared the selection would take the row
// out from under whoever was about to send it somewhere.
SelectedLocalEntry = wasSelected is null
? null
: LocalEntries.FirstOrDefault(
row => string.Equals(row.FullPath, wasSelected, StringComparison.OrdinalIgnoreCase));
}
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);
}
}
/// <remarks>
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
/// picker once for the hosts and again for the buckets.
/// </remarks>
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
{
RefreshPickers(hosts: false, buckets: true);
}
else
{
RefreshPickers(hosts: true, buckets: false);
}
}
/// <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() => RefreshPickers(hosts: true, buckets: true);
private void RefreshPickers(bool hosts, bool buckets)
{
refreshingPickers = true;
try
{
if (vault is not { } open)
{
Hosts.ReplaceAll([]);
return;
}
if (hosts)
{
Hosts.ReplaceAll(open.Hosts);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
}
if (buckets)
{
Buckets.ReplaceAll(open.ObjectStores);
SelectedBucket =
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
}
finally
{
refreshingPickers = false;
}
}
/// <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.");
/// <summary>Closes the connection log's open ticket, if there is one.</summary>
/// <remarks>
/// Written whole 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.
/// <para>
/// Its own method since a connection can now end two ways: closed by this screen, and lost. Both write
/// one entry and neither may write two, which is what the null-out is for.
/// </para>
/// </remarks>
private void CloseConnectionRecord(ConnectionOutcome outcome)
{
if (connected is not { } record)
{
return;
}
connected = null;
connectionLog?.Record(
record.Address,
record.HostLabel,
record.HostId,
ConnectionKind.Sftp,
record.StartedAt,
TimeProvider.System.GetUtcNow(),
outcome);
}
private async Task CloseSessionAsync()
{
if (session is { } open)
{
session = null;
await open.DisposeAsync().ConfigureAwait(true);
}
CloseConnectionRecord(ConnectionOutcome.Closed);
IsConnected = false;
LostConnection = null;
reconnectTo = null;
ConnectedTo = null;
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
RemotePath = string.Empty;
RemotePathDraft = string.Empty;
RemoteEntries.Clear();
RemoteTrail.Clear();
SelectedRemoteEntry = null;
// Both questions go with the session they were about, and the box either of them was filled in
// from with them. A form left open over a pane that is no longer connected to anything would
// commit against RequireSession's own refusal.
PendingRemoteDeletion = null;
PendingRemoteMove = null;
PendingRemotePermissions = null;
RemoteMoveTarget = string.Empty;
remoteListingIsStale = false;
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
}
/// <remarks>
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
/// instead of one for the first change and one for the rest.
/// </remarks>
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
post(() =>
{
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
// button that will read this file again.
if (e.Transfer.State is TransferState.Completed)
{
// A staged download is not finished when the queue says so — it is finished when the bytes
// reach the document the person picked, and only the head can put them there. So the copy
// is released by the delivery rather than here, or it would be deleted on the way.
if (deliveries.Remove(e.Transfer.Id, out var deliver))
{
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
}
else
{
ReleaseStaged(e.Transfer.LocalPath);
}
// Whichever pane the bytes landed in is showing that directory as it was before they did.
NoteTransferLanded(e.Transfer);
}
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
{
existing.Transfer = e.Transfer;
}
else
{
Transfers.Add(new TransferRowViewModel(e.Transfer));
}
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
// rows count as active without the collection itself gaining or losing a row, so the strip's own
// count chip needs its own raise here.
OnPropertyChanged(nameof(ActiveTransfersLabel));
ActivityChanged?.Invoke(this, EventArgs.Empty);
});
/// <remarks>
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled.
/// <para>
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
/// </para>
/// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
{
if (IsBusy)
{
if (refusedWhileBusy is not null)
{
Status = refusedWhileBusy;
}
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;
}
// The one place every command on this screen passes through on its way out, which makes it the one
// place a listing that went stale while it was running can safely be settled.
await SettleRemoteListingAsync().ConfigureAwait(true);
}
partial void OnSelectedHostChanged(HostRowViewModel? value)
{
if (!refreshingPickers)
{
chosenHost = value?.EntityId;
// A person moving to another host gets an empty box rather than one still showing a password
// that, by typedPasswordFor, would no longer be sent.
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
{
TypedPassword = string.Empty;
}
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
{
if (!refreshingPickers)
{
chosenBucket = value?.EntityId;
}
}
/// <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)
{
// PERMS is the one item action that is not offered on both destinations; see
// CanChangeRemotePermissions.
OnPropertyChanged(nameof(CanChangeRemotePermissions));
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));
}
/// <remarks>
/// A fresh connection clears it, which is what puts the strip away — and it is here rather than in each
/// connect path so that every way of connecting takes the last failure down with it.
/// </remarks>
partial void OnIsConnectedChanged(bool value)
{
if (value)
{
LostConnection = null;
}
RaiseRemotePaneState();
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanUpload));
RaiseItemActionState();
// 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));
OnPropertyChanged(nameof(CanEditRemote));
OnPropertyChanged(nameof(SelectedRemoteName));
PendingRemoteDeletion = null;
PendingRemoteMove = null;
PendingRemotePermissions = null;
}
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
{
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
RaiseItemActionState();
}
partial void OnPendingRemoteMoveChanged(RemoteMoveRequest? value)
{
OnPropertyChanged(nameof(IsMovingRemote));
RaiseItemActionState();
}
partial void OnPendingRemotePermissionsChanged(RemotePermissionsRequest? value)
{
OnPropertyChanged(nameof(IsChangingRemotePermissions));
RaiseItemActionState();
}
partial void OnRemoteEditChanged(RemoteEditSession? value)
{
OnPropertyChanged(nameof(IsEditingRemote));
OnPropertyChanged(nameof(CanEditRemote));
}
partial void OnRemoteEditConflictChanged(string? value) =>
OnPropertyChanged(nameof(HasRemoteEditConflict));
/// <summary>Raises the three flags that answer "what is in the remote pane".</summary>
/// <remarks>
/// Together, because they are one fact read three ways — see <see cref="ShowsRemoteContents"/> — and a
/// caller that raised some of them would leave an invitation drawn over a listing, or neither.
/// </remarks>
private void RaiseRemotePaneState()
{
OnPropertyChanged(nameof(IsConnectionLost));
OnPropertyChanged(nameof(ShowsRemoteContents));
OnPropertyChanged(nameof(ShowsRemoteInvitation));
}
/// <summary>Raises the flags the three item buttons and their two forms are drawn from.</summary>
/// <remarks>
/// Together, because they are one fact — is there a question on screen, and is anything connected —
/// read five ways, and a caller that raised some of them would leave a button live beside a form that
/// says it is not.
/// </remarks>
private void RaiseItemActionState()
{
OnPropertyChanged(nameof(IsAskingAboutRemoteEntry));
OnPropertyChanged(nameof(ShowsRemoteItemActions));
OnPropertyChanged(nameof(CanDeleteRemote));
OnPropertyChanged(nameof(CanRenameRemote));
OnPropertyChanged(nameof(CanMoveRemote));
OnPropertyChanged(nameof(CanChangeRemotePermissions));
OnPropertyChanged(nameof(CanEditRemote));
}
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
OnPropertyChanged(nameof(CanUpload));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}