Newer
Older
Sunfish / src / Sunfish.WinForms / ExplorerForm.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 15 KB 3.0
using System.Globalization;
using Sunfish.Dav;
using Sunfish.Net;

namespace Sunfish.WinForms;

/// <summary>
/// Explorar otro servidor (otro Sunfish o cualquier WebDAV) y copiar en los dos sentidos, con el
/// mismo aspecto compacto que la ventana principal. Los Sunfish de la red local aparecen solos
/// (DNS-SD); tambien se puede escribir una direccion. Se suben ficheros y carpetas arrastrandolos.
/// </summary>
internal sealed class ExplorerForm : Form
{
    private readonly ToolTip _tips = new();
    private readonly ComboBox _address = new() { Location = new Point(6, 6), Width = 250, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
    private readonly Button _go = new() { Text = "ยป", Size = new Size(26, 23), Location = new Point(260, 5), Anchor = AnchorStyles.Top | AnchorStyles.Right, UseVisualStyleBackColor = true };
    private readonly Label _path = new() { Location = new Point(6, 34), AutoSize = false, Size = new Size(280, 18), AutoEllipsis = true, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
    private readonly ListView _list = new()
    {
        Location = new Point(0, 54), Size = new Size(292, 270), View = View.Details, FullRowSelect = true, HideSelection = false,
        AllowDrop = true, BorderStyle = BorderStyle.FixedSingle,
        Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
    };
    private readonly Label _status = new() { Location = new Point(6, 328), Size = new Size(280, 18), AutoEllipsis = true, ForeColor = SystemColors.GrayText, Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right };
    private readonly Button _up, _download, _upload, _mkdir, _delete;
    private readonly Button _close = new() { Size = new Size(70, 26), UseVisualStyleBackColor = true, Anchor = AnchorStyles.Bottom | AnchorStyles.Right };
    private readonly ImageList _icons = new() { ColorDepth = ColorDepth.Depth32Bit, ImageSize = new Size(UiImages.Px(16), UiImages.Px(16)) };

    private List<DiscoveredServer> _discovered = new();
    private DavClient? _client;
    private string _folder = "/";
    private string? _user, _password;
    private bool _busy;
    private static string? _lastLocalFolder;

    public ExplorerForm()
    {
        _up = UiImages.IconButton("up", Texts.ExplorerUp, _tips);
        _download = UiImages.IconButton("download", Texts.ExplorerDownload, _tips);
        _upload = UiImages.IconButton("upload", Texts.ExplorerUpload, _tips);
        _mkdir = UiImages.IconButton("plus", Texts.ExplorerNewFolder, _tips);
        _delete = UiImages.IconButton("delete", Texts.ExplorerDelete, _tips);

        SuspendLayout();
        AutoScaleDimensions = new SizeF(96F, 96F);
        AutoScaleMode = AutoScaleMode.Dpi;
        Text = Texts.ExplorerTitle;
        ClientSize = new Size(292, 384);
        MinimumSize = new Size(280, 300);
        StartPosition = FormStartPosition.CenterParent;
        ShowInTaskbar = true;

        _icons.Images.Add("dir", UiImages.Get("foldericon"));
        _icons.Images.Add("file", UiImages.Get("file"));
        _list.SmallImageList = _icons;
        _list.Columns.Add(Texts.ColName, 160);
        _list.Columns.Add(Strings.ColSize, 60, HorizontalAlignment.Right);
        _list.Columns.Add(Strings.ColDate, 110);

        int y = 384 - 31;
        var buttons = new[] { _up, _download, _upload, _mkdir, _delete };
        for (int i = 0; i < buttons.Length; i++)
        {
            buttons[i].Location = new Point(6 + i * 29 + (i >= 1 ? 6 : 0), y);
            buttons[i].Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
        }
        _close.Text = Texts.Close;
        _close.Location = new Point(292 - 76, y);
        _close.Click += (_, _) => Close();
        _tips.SetToolTip(_address, Texts.ExplorerAddressTip);
        _tips.SetToolTip(_go, Texts.ExplorerGo);

        Controls.AddRange([_address, _go, _path, _list, _status, .. buttons, _close]);
        ResumeLayout(false);
        PerformLayout();

        _go.Click += async (_, _) => await ConnectAsync(_address.Text);
        _address.KeyDown += async (_, e) =>
        {
            if (e.KeyCode == Keys.Enter)
            {
                e.SuppressKeyPress = true;
                await ConnectAsync(_address.Text);
            }
        };
        _address.SelectionChangeCommitted += async (_, _) =>
        {
            if (_address.SelectedIndex >= 0 && _address.SelectedIndex < _discovered.Count)
                await ConnectAsync(_discovered[_address.SelectedIndex].Url);
        };
        _list.DoubleClick += async (_, _) => await OpenSelectedAsync();
        _list.KeyDown += async (_, e) =>
        {
            if (e.KeyCode == Keys.Enter) await OpenSelectedAsync();
            if (e.KeyCode == Keys.Back) await GoUpAsync();
            if (e.KeyCode == Keys.Delete) await DeleteAsync();
        };
        _list.DragEnter += (_, e) => e.Effect = _client is not null && e.Data?.GetDataPresent(DataFormats.FileDrop) == true ? DragDropEffects.Copy : DragDropEffects.None;
        _list.DragDrop += async (_, e) =>
        {
            if (e.Data?.GetData(DataFormats.FileDrop) is string[] paths)
                await UploadAsync(paths);
        };
        _up.Click += async (_, _) => await GoUpAsync();
        _download.Click += async (_, _) => await DownloadAsync();
        _upload.Click += async (_, _) => await PickAndUploadAsync();
        _mkdir.Click += async (_, _) => await NewFolderAsync();
        _delete.Click += async (_, _) => await DeleteAsync();
        UpdateButtons();
    }

    protected override async void OnShown(EventArgs e)
    {
        base.OnShown(e);
        await DiscoverAsync();
    }

    protected override void OnFormClosed(FormClosedEventArgs e)
    {
        _client?.Dispose();
        base.OnFormClosed(e);
    }

    // ------------------------------------------------------------------ conexion

    private async Task DiscoverAsync()
    {
        SetStatus(Texts.ExplorerSearching);
        try
        {
            _discovered = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2));
        }
        catch (Exception e) when (e is System.Net.Sockets.SocketException or OperationCanceledException)
        {
            _discovered = new();
        }
        if (IsDisposed)
            return;
        string typed = _address.Text;
        _address.Items.Clear();
        foreach (var s in _discovered)
            _address.Items.Add($"{s.Name}  ({s.Address}:{s.Port})");
        _address.Text = typed;
        SetStatus(_discovered.Count == 0 ? Texts.ExplorerNoneFound : Texts.ExplorerFound(_discovered.Count));
        if (_discovered.Count > 0 && _client is null)
            _address.DroppedDown = true;
    }

    private async Task ConnectAsync(string text)
    {
        if (!DavClient.TryParseAddress(text, out var server, out var path))
        {
            SetStatus(Texts.ExplorerBadAddress);
            return;
        }
        _client?.Dispose();
        _client = new DavClient(server, _user, _password);
        await ShowFolderAsync(path);
    }

    private async Task ShowFolderAsync(string folder)
    {
        if (_client is null)
            return;
        await RunAsync(async () =>
        {
            var items = await WithCredentialsAsync(c => c.ListAsync(folder));
            _folder = folder.Length > 1 ? folder.TrimEnd('/') : "/";
            _path.Text = _client!.Server.Authority + _folder;
            _list.BeginUpdate();
            _list.Items.Clear();
            foreach (var item in items)
            {
                var row = new ListViewItem(item.Name, item.IsCollection ? "dir" : "file") { Tag = item };
                row.SubItems.Add(item.IsCollection ? "" : FormatSize(item.Size));
                row.SubItems.Add(item.Modified?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "");
                _list.Items.Add(row);
            }
            _list.EndUpdate();
            SetStatus(Texts.ExplorerItems(items.Count));
        });
    }

    /// <summary>Si el servidor pide usuario (401), se pregunta y se repite con las credenciales.</summary>
    private async Task<T> WithCredentialsAsync<T>(Func<DavClient, Task<T>> action)
    {
        while (true)
        {
            try
            {
                return await action(_client!);
            }
            catch (DavException e) when (e.Status == 401)
            {
                using var dialog = new PasswordDialog(Texts.ExplorerLogin(_client!.Server.Authority), askName: true, askRepeat: false);
                if (dialog.ShowDialog(this) != DialogResult.OK)
                    throw;
                _user = dialog.UserName;
                _password = dialog.Password;
                var server = _client.Server;
                _client.Dispose();
                _client = new DavClient(server, _user, _password);
            }
        }
    }

    private Task WithCredentialsAsync(Func<DavClient, Task> action) =>
        WithCredentialsAsync<bool>(async c => { await action(c); return true; });

    // ------------------------------------------------------------------ acciones

    private IEnumerable<DavItem> Selected() =>
        _list.SelectedItems.Cast<ListViewItem>().Select(i => (DavItem)i.Tag!);

    private async Task OpenSelectedAsync()
    {
        var item = Selected().FirstOrDefault();
        if (item is { IsCollection: true })
            await ShowFolderAsync(item.Path);
        else if (item is not null)
            await DownloadAsync();
    }

    private Task GoUpAsync() => _folder == "/" ? Task.CompletedTask : ShowFolderAsync(DavClient.Parent(_folder));

    private async Task DownloadAsync()
    {
        var items = Selected().ToList();
        if (items.Count == 0 || _client is null)
            return;
        using var picker = new FolderBrowserDialog
        {
            Description = Texts.ExplorerDownloadTo,
            UseDescriptionForTitle = true,
            SelectedPath = _lastLocalFolder ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        };
        if (picker.ShowDialog(this) != DialogResult.OK)
            return;
        _lastLocalFolder = picker.SelectedPath;
        await RunAsync(async () =>
        {
            var progress = Progress();
            foreach (var item in items)
                await WithCredentialsAsync(c => c.DownloadTreeAsync(item, picker.SelectedPath, progress));
            SetStatus(Texts.ExplorerDone);
        });
    }

    private async Task PickAndUploadAsync()
    {
        if (_client is null)
            return;
        using var dialog = new OpenFileDialog { Multiselect = true, Title = Texts.ExplorerUpload };
        if (dialog.ShowDialog(this) == DialogResult.OK)
            await UploadAsync(dialog.FileNames);
    }

    private async Task UploadAsync(string[] paths)
    {
        if (_client is null || paths.Length == 0)
            return;
        string folder = _folder;
        await RunAsync(async () =>
        {
            var progress = Progress();
            foreach (var path in paths)
                await WithCredentialsAsync(c => c.UploadTreeAsync(path, folder, progress));
        });
        await ShowFolderAsync(folder);
    }

    private async Task NewFolderAsync()
    {
        if (_client is null)
            return;
        using var dialog = new NameDialog(Texts.ExplorerNewFolder);
        if (dialog.ShowDialog(this) != DialogResult.OK || dialog.Value.Length == 0)
            return;
        string folder = _folder;
        await RunAsync(() => WithCredentialsAsync(c => c.CreateFolderAsync(DavClient.Combine(folder, dialog.Value))));
        await ShowFolderAsync(folder);
    }

    private async Task DeleteAsync()
    {
        var items = Selected().ToList();
        if (items.Count == 0 || _client is null)
            return;
        string question = items.Count == 1 ? Texts.ExplorerDeleteOne(items[0].Name) : Texts.ExplorerDeleteMany(items.Count);
        if (MessageBox.Show(this, question, Text, MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
            return;
        string folder = _folder;
        await RunAsync(async () =>
        {
            foreach (var item in items)
                await WithCredentialsAsync(c => c.DeleteAsync(item.Path, item.IsCollection));
        });
        await ShowFolderAsync(folder);
    }

    /// <summary>Ejecuta una operacion de red mostrando el error, sin bloquear la ventana.</summary>
    private async Task RunAsync(Func<Task> action)
    {
        if (_busy)
            return;
        _busy = true;
        UseWaitCursor = true;
        UpdateButtons();
        try
        {
            await action();
        }
        catch (DavException e)
        {
            SetStatus(e.Status == 403 ? Strings.ErrorText(403) : e.Message);
        }
        catch (Exception e) when (e is HttpRequestException or IOException or UnauthorizedAccessException or TaskCanceledException)
        {
            SetStatus(e.Message);
        }
        finally
        {
            _busy = false;
            UseWaitCursor = false;
            UpdateButtons();
        }
    }

    private IProgress<long> Progress()
    {
        long total = 0;
        return new Progress<long>(n =>
        {
            total += n;
            SetStatus(Texts.ExplorerTransferred(FormatSize(total)));
        });
    }

    private void UpdateButtons()
    {
        bool connected = _client is not null && !_busy;
        foreach (var b in new[] { _up, _download, _upload, _mkdir, _delete })
            b.Enabled = connected;
    }

    private void SetStatus(string text) => _status.Text = text;

    private static string FormatSize(long bytes)
    {
        string[] units = ["B", "KB", "MB", "GB", "TB"];
        double value = bytes;
        int unit = 0;
        while (value >= 1024 && unit < units.Length - 1)
        {
            value /= 1024;
            unit++;
        }
        return unit == 0 ? $"{bytes} B" : value.ToString("0.0", CultureInfo.InvariantCulture) + " " + units[unit];
    }
}

/// <summary>Pide un nombre (carpeta nueva).</summary>
internal sealed class NameDialog : Form
{
    private readonly TextBox _text = new() { Width = 220 };

    public string Value => _text.Text.Trim();

    public NameDialog(string title)
    {
        SuspendLayout();
        AutoScaleDimensions = new SizeF(96F, 96F);
        AutoScaleMode = AutoScaleMode.Dpi;
        Text = title;
        FormBorderStyle = FormBorderStyle.FixedDialog;
        MaximizeBox = MinimizeBox = false;
        ShowInTaskbar = false;
        StartPosition = FormStartPosition.CenterParent;
        AutoSize = true;
        AutoSizeMode = AutoSizeMode.GrowAndShrink;
        Padding = new Padding(10, 6, 10, 10);
        var ok = new Button { Text = Texts.Ok, Size = new Size(70, 25), DialogResult = DialogResult.OK, UseVisualStyleBackColor = true };
        var cancel = new Button { Text = Texts.Cancel, Size = new Size(70, 25), DialogResult = DialogResult.Cancel, UseVisualStyleBackColor = true };
        var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Width = 226, Height = 32, Margin = new Padding(0, 10, 0, 0) };
        buttons.Controls.AddRange([cancel, ok]);
        var main = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false };
        main.Controls.Add(new Label { Text = Strings.FieldName + ":", AutoSize = true, Margin = new Padding(3, 6, 3, 1) });
        main.Controls.Add(_text);
        main.Controls.Add(buttons);
        Controls.Add(main);
        AcceptButton = ok;
        CancelButton = cancel;
        ResumeLayout(false);
        PerformLayout();
    }
}