Public Access
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:
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<T></c> is too general a key to be sure of.
|
||||
/// </remarks>
|
||||
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
|
||||
Reference in new issue
Block a user