Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
T
jaap-jan 5b32c76c95
ci / build and test (push) Successful in 3m49s
ci / android head (push) Successful in 3m55s
ci / api image (push) Successful in 6s
ci / desktop nightly (push) Successful in 1m22s
Android: the redesign — a Vaults tab with hosts, keychain and snippets, card lists, a keychain that can add, and files that act on what is chosen
The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded
Material font and a count of open sessions on Connections, which replaces the strip of shells above the
bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync
light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain
and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists
them), and back from either goes to the hosts. Settings becomes an account card and grouped sections:
security, vaults and history, files, this app.

Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the
host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts
CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a
hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar
stands down while the software keyboard is up.

The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a
password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with
labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get
a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar
(save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload
button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet
opens SFTP on that terminal's own host.

The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the
glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept
for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown,
checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills
behind, and lists, nav entries and icon buttons carry names for TalkBack.

Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a
password, connecting through the new sheet, files on the terminal's host with the selection bar and
its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_
FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass.
2026-10-04 15:50:09 +02:00

4443 lines
190 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 a host — not a bucket — is open, so that a terminal could be opened beside it.</summary>
internal bool IsHostConnected => IsConnected && 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",
};
/// <summary>Whether there is anything for an action to act on: a tick, or a chosen row.</summary>
/// <remarks>
/// The phone's question, and it is <see cref="RemoteBatch"/>'s own rule — ticks, or the selection when
/// there are none — asked as a yes or no. It decides whether the files screen's selection bar is up,
/// which is where that head keeps SAVE and the menu of the four things that change a file. Nothing on
/// the desktop reads it.
/// </remarks>
internal bool HasRemoteBatch => HasCheckedRemote || SelectedRemoteEntry is not null;
/// <summary>What the phone's selection bar says: the count when rows are ticked, else the row's name.</summary>
internal string RemoteBatchLabel =>
HasCheckedRemote ? CheckedRemoteLabel : SelectedRemoteEntry?.Name ?? string.Empty;
/// <summary>Whether the phone's menu of things to do to the chosen rows is up.</summary>
/// <remarks>
/// The phone moved RENAME, MOVE, PERMS and DELETE off a permanent block of buttons — seven of them over
/// half the screen, live with nothing chosen (audit M11) — and into a sheet raised from the selection
/// bar's ⋮. The sheet's openness is kept here rather than in the view so the system back gesture, which
/// the shell answers, can lower it. The desktop never sets it.
/// </remarks>
[ObservableProperty]
private bool isEntryMenuOpen;
[RelayCommand]
private void OpenEntryMenu() => IsEntryMenuOpen = true;
[RelayCommand]
private void CloseEntryMenu() => IsEntryMenuOpen = false;
/// <summary>Lets go of everything chosen: the ticks and the selection both.</summary>
/// <remarks>The ✕ on the phone's selection bar, which is how somebody says they did not mean it.</remarks>
[RelayCommand]
private void ClearRemoteBatch()
{
foreach (var row in RemoteEntries.Where(row => row.IsChecked))
{
row.IsChecked = false;
}
SelectedRemoteEntry = null;
IsEntryMenuOpen = false;
RaiseBatchState();
}
/// <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(HasRemoteBatch));
OnPropertyChanged(nameof(RemoteBatchLabel));
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)
{
OnPropertyChanged(nameof(IsHostConnected));
// 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)
{
OnPropertyChanged(nameof(IsHostConnected));
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));
OnPropertyChanged(nameof(HasRemoteBatch));
OnPropertyChanged(nameof(RemoteBatchLabel));
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));
}