Move files to and from a host over SFTP

M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a
transfer queue in a project of its own, and the two-pane browser the design
asked for replacing the screen that said it did not exist. Remote listings
carry names, sizes, modification times and a real drwxr-xr-x — nothing in this
repository could render a POSIX mode before — and the queue moves one file at a
time with progress, throughput and resume.

The design import assumed this would be an SFTP subsystem channel on
ISshConnection, beside the shell on a transport that is already up. SSH.NET
does not offer that: SftpClient derives from BaseClient and owns its own
transport, and there is no supported way to hand it an SshClient's session. So
file transfer opens a second authenticated connection, and it is named for
that rather than dressed up as a channel — OpenSftpAsync is on
ISftpSessionFactory, not on a connection. The difference is visible to a user:
the host records a second login, and a host whose password is typed each time
asks for it again on this screen. It goes through the same host key gate, the
same pin and the same two refusals a shell does, so a fingerprint approved for
a terminal is approved here and one approved here reaches the other machines
with the next sync. docs/design-import-gaps.md is corrected, and marked as the
one row where what shipped differs from what it predicted.

Nothing is written at its final name until it is complete. Every transfer goes
to a .dodossh-part file beside its destination and is renamed into place at the
end, so an interrupted transfer can never be mistaken for a finished one —
which matters most for what this screen is actually for, which is copying a
build artefact onto a server and then running it. A destination that already
exists is refused outright rather than overwritten: the queue has no way to
ask, and silently replacing a file somebody's process is serving is the worse
of the two failures. The remote pane has DELETE and MKDIR so that refusal is
not a dead end. A test against the container pins the assumption underneath all
of this — that SFTP's rename does not clobber.

Resume works within a run of the application and not across a restart, and the
limit is deliberate rather than unfinished. Nothing records which source wrote
a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure; a part file
found at startup is started over. Making it survive a restart needs the
preferences store this client still has not got. The offset a resume starts at
is the part file's own length rather than the transfer's recorded progress: a
cancellation can land between a write completing and the counter moving, and
only one of those two is a fact about the bytes that are there.

The queue and its connection outlive a lock, as shells do. LockAsync already
argues that locking must not destroy work in flight — it is what somebody does
when they walk away from the machine, which is exactly when a long transfer is
most likely to be running — so TransfersViewModel is created once and the vault
is attached on unlock and detached on lock. What locking takes is the host
list, and it has to: those rows carry decrypted secrets.

DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The
two answer different questions — one is about reaching a host, the other about
moving bytes and what to do when moving them stops halfway — and this is the
only client project that deliberately touches the local filesystem.

Three defects the tests found, none of which review would have. SftpPath.Name
answered an empty string for the root. NavigateRemoteAsync wrapped itself in
the busy guard, so navigating from inside another command did nothing at all
and the remote pane simply stayed empty after connecting, with no failure
anywhere to explain it. And opening an SFTP session per test made two
handshakes per test — this client learns a host key by being refused — which
pushed the SSH assembly past sshd's MaxStartups and failed a different few
unrelated tests each run; the session is shared through the fixture now, with
the reason written where the next person will hit it.

1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against
the OpenSSH container, the queue against a real temporary directory and a fake
host, and three more layout measurements because a screen this window has never
laid out is a screen never checked.

Not verified: the screen has not been looked at running. The layout harness
measures it at the window's minimum in three shapes, which is the class of
defect that has shipped here before, but reaching it in the application needs
the compose stack, the migrations, the API and a browser sign-in. What is still
absent — the status bar's transfer count, dragging between the panes,
transferring a directory, and sftp over a bastion — is in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 11:07:29 +02:00
parent 94e11f5e38
commit 04faef6597
32 changed files with 4933 additions and 53 deletions
+7 -2
View File
@@ -58,9 +58,13 @@ internal sealed partial class DodoSshApp : Application
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
var knownHosts = new VaultKnownHostStore();
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
// and the same host key decision, and composing two would mean two snapshots of the pins.
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
new SshNetConnectionFactory(knownHosts),
connections,
TimeProvider.System);
workspace.Start();
@@ -81,7 +85,8 @@ internal sealed partial class DodoSshApp : Application
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
TimeProvider.System);
TimeProvider.System,
connections);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
@@ -28,6 +28,7 @@
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</ItemGroup>
<ItemGroup>
@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
@@ -52,11 +53,10 @@ internal enum ShellState
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
/// three entries would make the eventual arrival of the other two look like a new product rather than a
/// milestone.
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
/// </para>
/// </remarks>
internal enum ShellScreen
@@ -64,7 +64,7 @@ internal enum ShellScreen
/// <summary>The host list and the terminals, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer. Nothing implements it yet.</summary>
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
Transfers = 1,
/// <summary>Everything in the vault that is not a host.</summary>
@@ -118,6 +118,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <remarks>
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
/// </remarks>
private readonly TransfersViewModel transfers;
private IVaultServer? connection;
private bool disposed;
@@ -132,6 +140,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
/// <param name="sftpSessions">
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
/// application — one type implements both — and a separate parameter because it is a separate capability
/// and the tests that drive this state machine have no use for it.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -140,6 +153,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null)
{
this.paths = paths;
@@ -151,6 +165,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.clock = clock;
this.passphraseProfile = passphraseProfile;
transfers = new TransfersViewModel(sftpSessions, clock);
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
@@ -222,6 +238,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks>
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
/// property that went null on lock would be a transfer nothing could report on afterwards.
/// </remarks>
internal TransfersViewModel Transfers => transfers;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
@@ -900,6 +924,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts);
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
@@ -947,6 +975,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// reappearing behind a lock screen.
knownHosts.Close();
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy.
transfers.Detach();
if (Vault is { } open)
{
Vault = null;
@@ -973,6 +1006,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
knownHosts.Close();
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it.
await transfers.DisposeAsync().ConfigureAwait(false);
if (Vault is { } open)
{
await open.DisposeAsync().ConfigureAwait(false);
@@ -0,0 +1,935 @@
using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param>
/// <param name="Path">The absolute path that reaches it.</param>
internal sealed record CrumbViewModel(string Name, string Path);
/// <summary>One remote file or directory, as a row.</summary>
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
{
internal SftpEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsNavigable;
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// <remarks>
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
/// </remarks>
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
internal string Modified => 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>One local file or directory, as a row.</summary>
/// <remarks>
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
/// </remarks>
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
{
internal LocalEntry Entry => entry;
internal string Name => entry.Name;
internal string FullPath => entry.FullPath;
internal bool IsNavigable => entry.IsDirectory;
internal bool IsFile => !entry.IsDirectory;
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}
/// <summary>How this screen writes a modification time.</summary>
/// <remarks>
/// <para>
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
/// </para>
/// <para>
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
/// </para>
/// </remarks>
internal static class Timestamps
{
internal static string Format(DateTimeOffset moment) =>
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
/// <summary>One transfer, as a row in the queue.</summary>
/// <remarks>
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
/// </remarks>
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
[ObservableProperty]
private TransferSnapshot transfer = snapshot;
internal Guid Id => Transfer.Id;
internal string Name => Transfer.Name;
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
internal string Path => Transfer.Direction is TransferDirection.Download
? Transfer.RemotePath
: Transfer.LocalPath;
internal double Percent => Transfer.Fraction * 100;
/// <summary>
/// What the row says about where it has got to.
/// </summary>
/// <remarks>
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
/// whether it is still moving.
/// </remarks>
internal string Progress => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running =>
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
TransferState.Completed => ByteSize.Format(Transfer.Length),
TransferState.Cancelled when Transfer.Transferred > 0 =>
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
TransferState.Cancelled => "stopped",
_ => Transfer.Failure ?? "failed",
};
internal string StateLabel => Transfer.State switch
{
TransferState.Queued => "QUEUED",
TransferState.Running => "RUNNING",
TransferState.Completed => "DONE",
TransferState.Cancelled => "STOPPED",
_ => "FAILED",
};
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
internal bool IsFinished => Transfer.IsFinished;
internal bool CanResume => Transfer.CanResume;
internal bool HasFailed => Transfer.State is TransferState.Failed;
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
/// <remarks>
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
/// wording differs. See <see cref="RetryLabel"/>.
/// </remarks>
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
/// <remarks>
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
/// raising only the ones that happened to change is how a progress bar moves under a label that still
/// says "queued".
/// </remarks>
partial void OnTransferChanged(TransferSnapshot value)
{
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsFinished));
OnPropertyChanged(nameof(CanResume));
OnPropertyChanged(nameof(CanRetry));
OnPropertyChanged(nameof(HasFailed));
OnPropertyChanged(nameof(RetryLabel));
}
}
/// <summary>
/// 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 VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private ISftpSession? session;
private bool disposed;
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
{
this.sftp = sftp;
// The supplier answers with whatever session is current at the moment a transfer starts, which is
// what lets a queue survive a disconnect and reconnect without every queued row failing.
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
// which is the life of the process.
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
}
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
/// <remarks>
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <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;
[ObservableProperty]
private string status = "Choose a host and connect to browse its files.";
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isConnected;
/// <summary>The account and endpoint actually dialled, once connected.</summary>
[ObservableProperty]
private string? connectedTo;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
internal bool SelectedHostAsksForAPassword =>
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
// ---- The remote pane ----
[ObservableProperty]
private string remotePath = string.Empty;
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
[ObservableProperty]
private RemoteEntryRowViewModel? selectedRemoteEntry;
/// <summary>What a new directory would be called, when the user is making one.</summary>
[ObservableProperty]
private string newRemoteFolder = string.Empty;
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
// ---- 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; } = [];
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
{
vault = openVault;
knownHosts = hostKeys;
RefreshHosts();
// Read once per unlock rather than per navigation: a drive appearing while the application is open
// is possible and rare, and probing every removable drive on every click into a folder is not.
LocalRoots.Clear();
foreach (var root in LocalDirectory.Roots())
{
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
}
RefreshLocalCommand.Execute(null);
}
/// <summary>
/// Gives up the vault, keeping the connection and anything in flight.
/// </summary>
/// <remarks>
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
/// </remarks>
internal void Detach()
{
vault = null;
knownHosts = null;
Hosts.Clear();
SelectedHost = null;
TypedPassword = string.Empty;
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
{
if (vault is not { } open || SelectedHost is not { } row)
{
Status = "Choose a host first.";
return;
}
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
{
Status = refusal;
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
try
{
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
}
catch (SshHostKeyUnknownException exception)
{
// First contact, decided here rather than inherited from a terminal. File transfer is
// its own connection, so it makes its own trust decision — and the pin it writes is the
// same pin a shell would then find.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
return;
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. Nothing was connected.";
return;
}
TypedPassword = string.Empty;
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>
/// Closes the file-transfer session.
/// </summary>
/// <remarks>
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
/// accident.
/// </remarks>
[RelayCommand]
private async Task DisconnectAsync()
{
if (queue.IsBusy)
{
Status = "There are transfers still running. Stop them first, or let them finish.";
return;
}
await CloseSessionAsync().ConfigureAwait(true);
Status = "Disconnected. Anything already transferred is where it landed.";
}
/// <summary>Pins the offered host key and connects.</summary>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation || knownHosts is null)
{
return;
}
try
{
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
return;
}
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
/// </summary>
/// <remarks>
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
/// with two clicks instead of one.
/// </remarks>
[RelayCommand]
private void RejectHostKey()
{
var wasOffered = PendingHostKey is not null;
PendingHostKey = null;
HostKeyMismatch = null;
Status = wasOffered
? "The host key was not trusted, so nothing was connected."
: "Nothing was connected.";
}
// ---- Navigation ----
/// <summary>Goes to a remote directory.</summary>
[RelayCommand]
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
{
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
}
/// <summary>Goes up one remote directory.</summary>
[RelayCommand]
private async Task RemoteUpAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
}
}
/// <summary>Re-reads the remote directory.</summary>
[RelayCommand]
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
{
if (RemotePath.Length > 0)
{
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
/// </summary>
/// <remarks>
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
/// directory reports as a link and resolving every one of them would be a round trip per row. The
/// failure, when it is a link to a file, is the server's own and says so.
/// </remarks>
[RelayCommand]
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
{
if (SelectedRemoteEntry is { IsNavigable: true } row)
{
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>Goes to a local directory.</summary>
[RelayCommand]
private void GoLocal(string path) => NavigateLocal(path);
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
/// <remarks>
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
/// </remarks>
[RelayCommand]
private void LocalUp()
{
if (LocalDirectory.Parent(LocalPath) is { } parent)
{
NavigateLocal(parent);
return;
}
Status = "That is the top of this drive. Use the drive list to go to another one.";
}
/// <summary>Re-reads the local directory.</summary>
[RelayCommand]
private void RefreshLocal() => NavigateLocal(LocalPath);
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
[RelayCommand]
private void OpenLocal()
{
if (SelectedLocalEntry is { IsNavigable: true } row)
{
NavigateLocal(row.FullPath);
}
}
// ---- Moving files ----
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
[RelayCommand]
private void Download()
{
if (SelectedRemoteEntry is not { IsFile: true } row)
{
Status = "Choose a file on the host to download.";
return;
}
var destination = Path.Combine(LocalPath, row.Name);
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
Status = $"Queued {row.Name} for download into {LocalPath}.";
}
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
[RelayCommand]
private void Upload()
{
if (SelectedLocalEntry is not { IsFile: true } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
var destination = SftpPath.Combine(RemotePath, row.Name);
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
Status = $"Queued {row.Name} for upload into {RemotePath}.";
}
/// <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);
}
}
/// <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>
/// Deletes the chosen remote file, or an empty directory.
/// </summary>
/// <remarks>
/// Not recursive, and the refusal comes from the server rather than from a check here — see
/// <c>ISftpSession.DeleteAsync</c>. It 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.
/// </remarks>
[RelayCommand]
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
{
if (SelectedRemoteEntry is not { } row)
{
Status = "Choose something on the host to delete.";
return;
}
await RunAsync(
$"Deleting {row.Name}…",
async () =>
{
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
Status = $"Deleted {row.Name}.";
}).ConfigureAwait(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));
}
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
{
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
RemotePath = path;
SelectedRemoteEntry = null;
RemoteEntries.Clear();
foreach (var entry in entries)
{
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
}
RemoteTrail.Clear();
foreach (var (name, crumb) in SftpPath.Trail(path))
{
RemoteTrail.Add(new CrumbViewModel(name, crumb));
}
Status = string.Empty;
}
/// <remarks>
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
/// something that returns before the click has finished being handled.
/// </remarks>
private void NavigateLocal(string path)
{
try
{
var entries = LocalDirectory.List(path);
LocalPath = Path.GetFullPath(path);
SelectedLocalEntry = null;
LocalEntries.Clear();
foreach (var entry in entries)
{
LocalEntries.Add(new LocalEntryRowViewModel(entry));
}
RebuildLocalTrail();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
// pane would look like a directory that had become empty.
Status = $"{path} could not be read: {exception.Message}";
}
}
/// <remarks>
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
/// </remarks>
private void RebuildLocalTrail()
{
var crumbs = new List<CrumbViewModel>();
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
{
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
}
LocalTrail.Clear();
foreach (var crumb in crumbs)
{
LocalTrail.Add(crumb);
}
}
private void RefreshHosts()
{
Hosts.Clear();
if (vault is not { } open)
{
return;
}
foreach (var host in open.Hosts)
{
Hosts.Add(host);
}
SelectedHost ??= Hosts.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
private ISftpSession RequireSession() =>
session ?? throw new InvalidOperationException(
"This screen is not connected to a host, so there is nowhere to move the file.");
private async Task CloseSessionAsync()
{
if (session is { } open)
{
session = null;
await open.DisposeAsync().ConfigureAwait(true);
}
IsConnected = false;
ConnectedTo = null;
RemotePath = string.Empty;
RemoteEntries.Clear();
RemoteTrail.Clear();
SelectedRemoteEntry = null;
}
/// <remarks>
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
/// instead of one for the first change and one for the rest.
/// </remarks>
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
{
existing.Transfer = e.Transfer;
return;
}
Transfers.Add(new TransferRowViewModel(e.Transfer));
});
/// <remarks>
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled.
/// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
partial void OnIsConnectedChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanUpload));
}
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
OnPropertyChanged(nameof(CanDownload));
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
OnPropertyChanged(nameof(CanUpload));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}
@@ -1817,7 +1817,7 @@ internal sealed partial class VaultViewModel(
// Refused rather than quietly falling back to the password box. A host set up for key-only access
// that silently starts offering a password is the failure worth ruling out — the user asked for one
// thing and got another, and the host is the last place that would say so.
if (!TryBuildAuthentication(row.Host, out var authentication, out var refusal))
if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal))
{
Status = refusal;
return;
@@ -2087,8 +2087,38 @@ internal sealed partial class VaultViewModel(
/// answered by the more specific of the two rather than by whichever the code happened to check.
/// </para>
/// </remarks>
/// <summary>
/// Works out how to reach a host, or says why it cannot.
/// </summary>
/// <remarks>
/// The same resolution the Connect button performs, exposed because file transfer opens its own
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
/// username" would be a second place for a dangling binding to be silently turned back into a typed
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
/// password offered on the other.
/// </remarks>
internal bool TryBuildConnectionRequest(
HostSecret host,
string typedPassword,
[NotNullWhen(true)] out SshConnectionRequest? request,
[NotNullWhen(false)] out string? reason)
{
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
{
request = null;
return false;
}
request = new SshConnectionRequest(
host.Hostname, host.Port, authentication.Username, authentication.Credential);
return true;
}
private bool TryBuildAuthentication(
HostSecret host,
string typedPassword,
[NotNullWhen(true)] out HostAuthentication? authentication,
[NotNullWhen(false)] out string? reason)
{
@@ -2133,7 +2163,7 @@ internal sealed partial class VaultViewModel(
}
return Complete(
host.Username, new SshPasswordCredential(ConnectPassword), out authentication, out reason);
host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason);
}
/// <summary>
+9 -14
View File
@@ -184,20 +184,15 @@
</Grid>
<!-- ============ FILES ============ -->
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
Title="FILE TRANSFER"
Milestone="MILESTONE M2"
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
<views:NotBuiltScreen.Missing>
<sys:List x:TypeArguments="x:String">
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
</sys:List>
</views:NotBuiltScreen.Missing>
</views:NotBuiltScreen>
<!--
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
visibility is the shell's business and its data context is the transfers view model, and putting
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
exist.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ VAULT ============ -->
<!--
+6 -6
View File
@@ -7,11 +7,11 @@
<!--
Five destinations down the left edge.
Two of them — TRANSFERS and TEAM — reach screens that say they are not built. They are in the rail
anyway rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screens
behind these two say plainly what is missing, and a rail that quietly had three entries would make file
transfer and sharing look like a change of product rather than the next two milestones. Both are
recorded in docs/design-import-gaps.md.
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
plainly what is missing, and a rail that quietly had four entries would make sharing look like a change
of product rather than the next milestone. It is recorded in docs/design-import-gaps.md. FILES was the
other one until M2 built it.
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
of those hold the selection themselves, so a click moves the highlight before the shell can decide
@@ -30,7 +30,7 @@
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
ToolTip.Tip="File transfer over SSH. Not built yet — see the screen for what is missing." />
ToolTip.Tip="Move files to and from a host over SFTP" />
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}"
@@ -0,0 +1,404 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
x:Class="DodoSSH.Client.App.Views.TransfersScreen"
x:DataType="vm:TransfersViewModel">
<!--
File transfer: this machine on the left, the host on the right, and the queue underneath.
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
so browsing a host's files is a second authenticated connection rather than a second channel, and
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
about a file on the machine this client is developed on.
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
layer has not got. See docs/design-import-gaps.md.
-->
<UserControl.Styles>
<!--
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
described as deliberately rare. This is the one place it is spent.
-->
<Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Info}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="44,*,Auto">
<!-- ============ The host, and the connection ============ -->
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsEnabled="{Binding !IsConnected}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
holding a different value. This connection authenticates separately, so a password typed to open a
terminal was never offered here.
-->
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}"
IsEnabled="{Binding !IsConnected}" />
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
Command="{Binding ConnectCommand}"
IsVisible="{Binding !IsConnected}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
Command="{Binding DisconnectCommand}"
IsVisible="{Binding IsConnected}" />
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" />
</Border>
<TextBlock Grid.Column="5" Classes="hint" Text="{Binding Status}" FontSize="10.5"
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</Grid>
</Border>
<!-- ============ The two panes ============ -->
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== -->
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<!--
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
directory. Without this the pane is stuck on whichever drive the user profile is on.
-->
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="ghost" Content="{Binding Name}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
</StackPanel>
</Grid>
</Border>
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<StackPanel Orientation="Horizontal">
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
Foreground="{StaticResource TextDim}" />
</Button>
<TextBlock Classes="mono" Text="" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
</Grid>
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="11"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="3" Classes="hint" FontSize="11" Margin="24" MaxWidth="260"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
</Grid>
<!-- ==== The two directions ==== -->
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
<!--
Pointing at the pane the file is going to, which is the only reading that survives the panes
being side by side: the left-hand pane is this machine, so an upload points right.
-->
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
</StackPanel>
</Border>
<!-- ==== The host ==== -->
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<StackPanel Orientation="Horizontal">
<TextBlock Classes="mono" Text="/" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
Foreground="{StaticResource TextDim}" />
</Button>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Making a directory sits here, beside the path it would be made in, rather than with the queue's
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
"that name is already taken" is a dead end.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding IsConnected}">
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
</Grid>
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="11"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasRemoteEntries}">
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
IsVisible="{Binding !IsConnected}" />
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
Text="Nothing in this directory."
IsVisible="{Binding IsConnected}" />
</StackPanel>
</Grid>
</Grid>
<!-- ============ The queue ============ -->
<Border Grid.Row="2" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="12,7">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="one at a time · nothing lands at its final name until it is complete" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel>
<TextBlock Classes="hint" FontSize="10.5" Margin="12,4,12,14"
IsVisible="{Binding !HasTransfers}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="11"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="0,0,8,0">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="9"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="9.5"
Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}" />
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
<TextBlock Text="{Binding StateLabel}" />
</Border>
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
IsVisible="{Binding HasTransfers}" />
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
<!--
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
they arrive here on their own because this is a separate connection — a host trusted for a shell is
trusted for this too, but a host nobody has connected to at all is met here first.
-->
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
IsVisible="{Binding HasPendingHostKey}">
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
<TextBlock Classes="hint"
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
<SelectableTextBlock Classes="mono" FontSize="11" Foreground="{StaticResource Text}"
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Border>
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
IsVisible="{Binding HasHostKeyMismatch}">
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
<TextBlock Classes="hint"
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
HorizontalAlignment="Left" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,48 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The two-pane file browser and the transfer queue.
/// </summary>
/// <remarks>
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
/// <c>MainWindowViewModel.LockAsync</c>.
/// </remarks>
internal sealed partial class TransfersScreen : UserControl
{
public TransfersScreen()
{
InitializeComponent();
// Wired here rather than in the markup because it is a gesture rather than a binding, and because
// opening a directory has to be reachable without the mouse as well: both lists are ListBoxes, so
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
LocalList.DoubleTapped += OnLocalActivated;
RemoteList.DoubleTapped += OnRemoteActivated;
}
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
{
transfers.OpenLocalCommand.Execute(null);
}
}
/// <remarks>
/// Fire-and-forget, which is what a double-click on a directory can be: the command reports its own
/// failures onto the screen's status line, and awaiting it here would mean an event handler that returns
/// a task nothing observes — the same thing with a warning suppressed.
/// </remarks>
private void OnRemoteActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
{
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
}
}
}
@@ -391,6 +391,12 @@
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.transfer": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},