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