Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -3,12 +3,24 @@ using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
internal enum RemoteKind
{
/// <summary>A host, over SFTP.</summary>
Host,
/// <summary>An S3-compatible bucket.</summary>
Bucket,
}
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param>
/// <param name="Path">The absolute path that reaches it.</param>
@@ -240,7 +252,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private ISftpSession? session;
private IRemoteFileStore? session;
private ConnectionRecorder? connectionLog;
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
private IObjectStoreFactory? objectStores;
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
/// <remarks>
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
/// row it came from may have been replaced by a background sync. The address is the one that was
/// actually dialled, which is the whole point of capturing it at connect.
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
private bool disposed;
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
@@ -249,7 +274,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// 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 = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
@@ -271,6 +296,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>
/// Which sort of remote the right-hand pane is about to open.
/// </summary>
/// <remarks>
/// <para>
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
/// the host editor's authentication picker does — and the reason is that these two are not
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
/// to look at than two clearly separate choices.
/// </para>
/// <para>
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
/// keychain's categories do: a selection binding moves before a command could refuse it.
/// </para>
/// </remarks>
[ObservableProperty]
private RemoteKind remote;
/// <summary>Whether the picker is showing hosts.</summary>
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
/// <summary>Whether the picker is showing buckets.</summary>
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
/// <summary>
/// What the button that opens the remote says.
/// </summary>
/// <remarks>
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
/// DISCONNECT step look like a bug rather than the shape of the protocol.
/// </remarks>
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
/// <remarks>
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
@@ -288,6 +356,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private bool isConnected;
/// <summary>
/// Whether something is being dragged over the local pane, and whether it would be accepted.
/// </summary>
/// <remarks>
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
/// window that has stopped responding.
/// </remarks>
[ObservableProperty]
private bool isLocalDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropRefused;
/// <summary>The account and endpoint actually dialled, once connected.</summary>
[ObservableProperty]
private string? connectedTo;
@@ -304,7 +392,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <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 } };
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
// ---- The remote pane ----
@@ -376,10 +464,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
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)
/// <param name="openVault">The open keychain.</param>
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
/// <param name="log">
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
/// when the keychain locks still ends somewhere.
/// </param>
internal void Attach(
VaultViewModel openVault,
VaultKnownHostStore hostKeys,
ConnectionRecorder? log = null,
IObjectStoreFactory? buckets = null)
{
vault = openVault;
knownHosts = hostKeys;
connectionLog = log;
objectStores = buckets;
RefreshHosts();
@@ -408,13 +509,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
knownHosts = null;
Hosts.Clear();
Buckets.Clear();
SelectedHost = null;
SelectedBucket = null;
TypedPassword = string.Empty;
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
/// <summary>Shows one of the two kinds of remote in the picker.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
private void ShowRemote(RemoteKind kind) => Remote = kind;
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
Remote is RemoteKind.Bucket
? OpenBucketAsync(cancellationToken)
: ConnectToHostAsync(cancellationToken);
/// <summary>
/// Opens the chosen bucket.
/// </summary>
/// <remarks>
/// <para>
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
/// why the failure this reports is a listing failure rather than a connection one — there is no
/// connection to fail.
/// </para>
/// <para>
/// It goes through the same session field, the same queue and the same panes as a host, because by this
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
/// SFTP and needed no change.
/// </para>
/// </remarks>
private async Task OpenBucketAsync(CancellationToken cancellationToken)
{
if (objectStores is not { } factory)
{
Status = "This build cannot open buckets.";
return;
}
if (SelectedBucket is not { } row)
{
Status = "Choose a bucket first.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Opening {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
session = factory.Open(row.Store);
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Opened {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
{
if (vault is not { } open || SelectedHost is not { } row)
{
@@ -463,6 +629,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
// would disagree with the host's — and anybody comparing the two would be right to believe
// the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
@@ -624,34 +796,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[RelayCommand]
private void Download()
{
if (SelectedRemoteEntry is not { IsFile: true } row)
if (SelectedRemoteEntry is not { } 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}.";
QueueDownloads([row]);
}
/// <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)
if (SelectedLocalEntry is not { } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
var destination = SftpPath.Combine(RemotePath, row.Name);
QueueUploads([row.FullPath]);
}
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
/// <summary>
/// Queues every one of these local paths for upload into the remote directory showing.
/// </summary>
/// <remarks>
/// <para>
/// The one path both the button and a drop go through, so there is one set of rules about what can be
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
/// were dragged, from this window's own pane or from the file manager.
/// </para>
/// <para>
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
/// </para>
/// <para>
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
/// about all five.
/// </para>
/// </remarks>
internal void QueueUploads(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
Status = $"Queued {row.Name} for upload into {RemotePath}.";
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
var missing = 0;
foreach (var path in paths)
{
if (Directory.Exists(path))
{
directories++;
continue;
}
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
// paths in an OS drop come from another process, which is not obliged to be right about them.
if (!File.Exists(path))
{
missing++;
continue;
}
var length = new FileInfo(path).Length;
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
queue.Enqueue(TransferDirection.Upload, path, destination, length);
queued++;
}
Status = Describe(queued, "upload into", RemotePath, directories, missing);
}
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
/// <inheritdoc cref="QueueUploads" path="/remarks" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
{
ArgumentNullException.ThrowIfNull(rows);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
foreach (var row in rows)
{
if (!row.IsFile)
{
directories++;
continue;
}
queue.Enqueue(
TransferDirection.Download,
Path.Combine(LocalPath, row.Name),
row.FullPath,
row.Entry.Length);
queued++;
}
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
}
/// <remarks>
/// One sentence for both directions and every shape of partial success. What it must never do is stay
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
/// looking for the other two in a queue they are not in.
/// </remarks>
private static string Describe(int queued, string verb, string destination, int directories, int missing)
{
var files = queued == 1 ? "1 file" : $"{queued} files";
var said = queued == 0
? "Nothing was queued."
: $"Queued {files} for {verb} {destination}.";
if (directories > 0)
{
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
said += $" {folders} skipped — only files can be transferred.";
}
if (missing > 0)
{
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
said += $" {gone} no longer there.";
}
return said;
}
/// <summary>Stops one transfer.</summary>
@@ -919,10 +1204,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
SelectedHost ??= Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var bucket in open.ObjectStores)
{
Buckets.Add(bucket);
}
SelectedBucket ??= Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
private ISftpSession RequireSession() =>
private IRemoteFileStore RequireSession() =>
session ?? throw new InvalidOperationException(
"This screen is not connected to a host, so there is nowhere to move the file.");
@@ -934,6 +1228,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await open.DisposeAsync().ConfigureAwait(true);
}
// Written whole here rather than through an open/close ticket, because this connection is not one
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
// with a real terminal's.
if (connected is { } record)
{
connected = null;
connectionLog?.Record(
record.Address,
record.HostLabel,
record.HostId,
ConnectionKind.Sftp,
record.StartedAt,
TimeProvider.System.GetUtcNow(),
ConnectionOutcome.Closed);
}
IsConnected = false;
ConnectedTo = null;
RemotePath = string.Empty;
@@ -996,6 +1307,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
/// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
/// picker that has nothing to do with passwords.
/// </remarks>
partial void OnRemoteChanged(RemoteKind value)
{
OnPropertyChanged(nameof(ShowsHostPicker));
OnPropertyChanged(nameof(ShowsBucketPicker));
OnPropertyChanged(nameof(ConnectLabel));
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnIsConnectedChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));