Public Access
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ============ -->
|
||||
<!--
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user