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;
///
/// The two-pane file browser and the transfer queue.
///
///
///
/// Its data context is the TransfersViewModel, 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
/// MainWindowViewModel.LockAsync.
///
///
/// Everything about drag and drop is in this file and nothing about it is policy. The handlers pull
/// paths or rows out of a drop and hand them to QueueUploads/QueueDownloads; 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 docs/manual-checks.md.
///
///
internal sealed partial class TransfersScreen : UserControl
{
///
/// How remote rows travel while being dragged.
///
///
/// 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 DataFormat.File and never
/// as this, so "did this come from our own remote pane" needs no guessing.
///
private static readonly DataFormat RemoteEntries =
DataFormat.CreateInProcessFormat("dodossh/remote-entries");
/// How far the pointer moves before a press becomes a drag.
///
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
///
private const double DragThreshold = 4;
private PointerPressedEventArgs? pressed;
private Point pressedAt;
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;
// 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;
///
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
///
///
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
/// which is a property of the collection rather than of any row. Subscribing per row would be a
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
/// See TransfersViewModel.RemoteChecksChanged.
///
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
///
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (Transfers is { } transfers)
{
transfers.OpenLocalCommand.Execute(null);
}
}
///
/// 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.
///
private void OnRemoteActivated(object? sender, TappedEventArgs e)
{
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;
///
/// The drag starts here rather than on the press, because a press is also how a row is selected.
/// DoDragDropAsync wants the original PointerPressedEventArgs, so it is held from the
/// press until either the pointer moves far enough or the button comes back up.
///
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);
}
///
/// 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
/// , hence the asynchronous lookup — and hence a fire-and-forget call, because
/// nothing on a pointer-moved path can await.
///
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 ----
///
/// 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.
///
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);
}
}
///
/// 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.
///
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()
.ToList();
transfers.QueueUploads(paths);
}
private void ClearRemoteHighlight()
{
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = false;
transfers.IsRemoteDropRefused = false;
}
}
}
///
/// The remote rows carried by one drag.
///
///
/// A record wrapping the list rather than the list itself, because DataFormat.CreateInProcessFormat
/// keys on the type and a bare IReadOnlyList<T> is too general a key to be sure of.
///
internal sealed record RemoteDragPayload(IReadOnlyList Rows);