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);