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:
jaap-jan committed 2026-07-31 21:03:22 +02:00
commit 2caedd93ff
199 files changed
+31294 -775

No files matched your search

@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
/// The two-pane file browser and the transfer queue.
/// </summary>
/// <remarks>
/// <para>
/// 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>.
/// </para>
/// <para>
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
/// by hand — see <c>docs/manual-checks.md</c>.
/// </para>
/// </remarks>
internal sealed partial class TransfersScreen : UserControl
{
/// <summary>
/// How remote rows travel while being dragged.
/// </summary>
/// <remarks>
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
/// as this, so "did this come from our own remote pane" needs no guessing.
/// </remarks>
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
/// <remarks>
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
/// </remarks>
private const double DragThreshold = 4;
private PointerPressedEventArgs? pressed;
private Point pressedAt;
public TransfersScreen()
{
InitializeComponent();
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
LocalList.DoubleTapped += OnLocalActivated;
RemoteList.DoubleTapped += OnRemoteActivated;
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
// still selects, and the drag only begins once the pointer has moved far enough.
foreach (var list in new Control[] { LocalList, RemoteList })
{
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
}
}
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
if (Transfers is { } transfers)
{
transfers.OpenLocalCommand.Execute(null);
}
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
/// </remarks>
private void OnRemoteActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
if (Transfers is { } transfers)
{
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
}
}
// ---- Starting a drag ----
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
{
pressed = e;
pressedAt = e.GetPosition(this);
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
/// <remarks>
/// The drag starts here rather than on the press, because a press is also how a row is selected.
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
/// press until either the pointer moves far enough or the button comes back up.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (pressed is not { } origin || Transfers is not { } transfers)
{
return;
}
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
pressed = null;
return;
}
var moved = e.GetPosition(this) - pressedAt;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
pressed = null;
if (ReferenceEquals(sender, RemoteList))
{
StartRemoteDrag(origin, transfers);
}
else
{
_ = StartLocalDragAsync(origin, transfers);
}
}
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedRemoteEntry is not { } row)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
}
/// <remarks>
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
/// nothing on a pointer-moved path can await.
/// </remarks>
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|| TopLevel.GetTopLevel(this) is not { } top)
{
return;
}
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
if (file is null)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.CreateFile(file));
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
}
// ---- Accepting a drop ----
/// <remarks>
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
/// would be a copy from this machine to this machine, which is not what this screen is for.
/// </remarks>
private void OnLocalDragOver(object? sender, DragEventArgs e)
{
var accepted = e.DataTransfer.Contains(RemoteEntries);
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = accepted;
}
e.Handled = true;
}
private void OnLocalDragLeave(object? sender, DragEventArgs e)
{
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = false;
}
}
private void OnLocalDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
transfers.IsLocalDropTarget = false;
e.Handled = true;
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
{
transfers.QueueDownloads(payload.Rows);
}
}
/// <remarks>
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
/// after it.
/// </remarks>
private void OnRemoteDragOver(object? sender, DragEventArgs e)
{
var files = e.DataTransfer.Contains(DataFormat.File);
var connected = Transfers is { IsConnected: true };
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = files && connected;
transfers.IsRemoteDropRefused = files && !connected;
}
e.Handled = true;
}
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
private void OnRemoteDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
ClearRemoteHighlight();
e.Handled = true;
if (e.DataTransfer.TryGetFiles() is not { } files)
{
return;
}
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
// this screen declines rather than a thing it half does.
var paths = files
.Select(file => file.TryGetLocalPath())
.OfType<string>()
.ToList();
transfers.QueueUploads(paths);
}
private void ClearRemoteHighlight()
{
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = false;
transfers.IsRemoteDropRefused = false;
}
}
}
/// <summary>
/// The remote rows carried by one drag.
/// </summary>
/// <remarks>
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
/// keys on the type and a bare <c>IReadOnlyList&lt;T&gt;</c> is too general a key to be sure of.
/// </remarks>
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);