using System.Globalization;
using Sunfish.Dav;
using Sunfish.Net;
namespace Sunfish.Cli.Tui;
/// <summary>
/// Pestana Explorar: otros Sunfish de la red (DNS-SD) o cualquier WebDAV por direccion, con
/// descarga y subida en los dos sentidos. Las operaciones de red van en segundo plano y la
/// pantalla se redibuja al terminar o al avanzar.
/// </summary>
internal sealed partial class TuiApp
{
private List<DiscoveredServer>? _servers;
private int _serverSel;
private bool _searching;
private DavClient? _dav;
private string? _davUser, _davPassword;
private string _davFolder = "/";
private List<DavItem> _davItems = new();
private int _davSel;
private string? _davBusy;
private string? _davMessage;
private bool Browsing => _dav is not null;
private string ExploreHint() => Browsing ? TuiTexts.KeysExploreBrowse : TuiTexts.KeysExploreServers;
/// <summary>Teclas de la pestana; devuelve true si las ha usado (Esc vuelve a la lista de servidores).</summary>
private async Task<bool> ExploreKeyAsync(ConsoleKeyInfo key)
{
if (_servers is null && !_searching)
StartSearch();
if (_davBusy is not null)
return key.Key != ConsoleKey.Q; // durante una operacion solo se puede salir
if (!Browsing)
{
switch (key.Key)
{
case ConsoleKey.UpArrow: _serverSel = Math.Max(0, _serverSel - 1); return true;
case ConsoleKey.DownArrow: _serverSel = Math.Min(Math.Max(0, (_servers?.Count ?? 1) - 1), _serverSel + 1); return true;
case ConsoleKey.R: StartSearch(); return true;
case ConsoleKey.A:
_overlays.Add(new InputOverlay(TuiTexts.ExploreAddress, "", false, text => { Connect(text); return Task.CompletedTask; }));
return true;
case ConsoleKey.Enter when _servers is { Count: > 0 }:
Connect(_servers[_serverSel].Url);
return true;
}
return false;
}
var item = _davItems.Count > 0 ? _davItems[Math.Clamp(_davSel, 0, _davItems.Count - 1)] : null;
switch (key.Key)
{
case ConsoleKey.Escape:
_dav?.Dispose();
_dav = null;
_davMessage = null;
return true;
case ConsoleKey.UpArrow: _davSel = Math.Max(0, _davSel - 1); return true;
case ConsoleKey.DownArrow: _davSel = Math.Min(Math.Max(0, _davItems.Count - 1), _davSel + 1); return true;
case ConsoleKey.PageUp: _davSel = Math.Max(0, _davSel - 10); return true;
case ConsoleKey.PageDown: _davSel = Math.Min(Math.Max(0, _davItems.Count - 1), _davSel + 10); return true;
case ConsoleKey.Backspace:
if (_davFolder != "/")
Run(TuiTexts.ExploreLoading, () => LoadAsync(DavClient.Parent(_davFolder)));
return true;
case ConsoleKey.R:
Run(TuiTexts.ExploreLoading, () => LoadAsync(_davFolder));
return true;
case ConsoleKey.Enter when item is { IsCollection: true }:
Run(TuiTexts.ExploreLoading, () => LoadAsync(item.Path));
return true;
case ConsoleKey.D or ConsoleKey.Enter when item is not null:
_overlays.Add(new InputOverlay(TuiTexts.ExploreDownloadTo, Environment.CurrentDirectory, false, text =>
{
string target = text.Trim().Trim('"');
if (!Directory.Exists(target))
{
_overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(target)));
return Task.CompletedTask;
}
Run(TuiTexts.ExploreDownloading(item.Name), async () =>
{
await WithLoginAsync(c => c.DownloadTreeAsync(item, target, Progress()));
_davMessage = TuiTexts.ExploreDone;
});
return Task.CompletedTask;
}));
return true;
case ConsoleKey.U:
_overlays.Add(new InputOverlay(TuiTexts.ExploreUploadFrom, Environment.CurrentDirectory, false, text =>
{
string source = text.Trim().Trim('"');
if (!File.Exists(source) && !Directory.Exists(source))
{
_overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(source)));
return Task.CompletedTask;
}
string folder = _davFolder;
Run(TuiTexts.ExploreUploading(Path.GetFileName(Path.TrimEndingDirectorySeparator(source))), async () =>
{
await WithLoginAsync(c => c.UploadTreeAsync(source, folder, Progress()));
await LoadAsync(folder);
_davMessage = TuiTexts.ExploreDone;
});
return Task.CompletedTask;
}));
return true;
case ConsoleKey.M:
_overlays.Add(new InputOverlay(TuiTexts.ExploreNewFolder, "", false, name =>
{
if (name.Trim().Length == 0)
return Task.CompletedTask;
string folder = _davFolder;
Run(TuiTexts.ExploreLoading, async () =>
{
await WithLoginAsync(c => c.CreateFolderAsync(DavClient.Combine(folder, name.Trim())));
await LoadAsync(folder);
});
return Task.CompletedTask;
}));
return true;
case ConsoleKey.Delete when item is not null:
_overlays.Add(new ConfirmOverlay(TuiTexts.ExploreDeleteQuestion(item.Name), () =>
{
string folder = _davFolder;
Run(TuiTexts.ExploreLoading, async () =>
{
await WithLoginAsync(c => c.DeleteAsync(item.Path, item.IsCollection));
await LoadAsync(folder);
});
return Task.CompletedTask;
}));
return true;
}
return false;
}
private void StartSearch()
{
_searching = true;
_ = Task.Run(async () =>
{
try
{
_servers = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2));
}
catch (Exception e) when (e is System.Net.Sockets.SocketException or OperationCanceledException)
{
_servers = new();
}
_searching = false;
_serverSel = 0;
_events.Writer.TryWrite(null);
});
}
private void Connect(string address)
{
if (!DavClient.TryParseAddress(address, out var server, out var path))
{
_overlays.Add(new MessageOverlay(TuiTexts.ExploreBadAddress));
return;
}
_dav?.Dispose();
_dav = new DavClient(server, _davUser, _davPassword);
_davItems = new();
Run(TuiTexts.ExploreLoading, () => LoadAsync(path));
}
private async Task LoadAsync(string folder)
{
var items = await WithLoginAsync(c => c.ListAsync(folder));
_davFolder = folder.Length > 1 ? folder.TrimEnd('/') : "/";
_davItems = items;
_davSel = 0;
_davMessage = TuiTexts.ExploreItems(items.Count);
}
/// <summary>Con 401 se piden usuario y contrasena (en la TUI) y se repite.</summary>
private async Task<T> WithLoginAsync<T>(Func<DavClient, Task<T>> action)
{
while (true)
{
try
{
return await action(_dav!);
}
catch (DavException e) when (e.Status == 401)
{
var credentials = await AskCredentialsAsync(_dav!.Server.Authority);
if (credentials is null)
throw;
(_davUser, _davPassword) = credentials.Value;
var server = _dav.Server;
_dav.Dispose();
_dav = new DavClient(server, _davUser, _davPassword);
}
}
}
private Task WithLoginAsync(Func<DavClient, Task> action) =>
WithLoginAsync<bool>(async c => { await action(c); return true; });
/// <summary>Muestra dos preguntas y espera las respuestas (las teclas las atiende el bucle principal).</summary>
private Task<(string, string)?> AskCredentialsAsync(string server)
{
var done = new TaskCompletionSource<(string, string)?>();
_overlays.Add(new InputOverlay(TuiTexts.ExploreUser(server), _davUser ?? "", false, user =>
{
_overlays.Add(new InputOverlay(Strings.FieldPassword + ":", "", true, password =>
{
done.TrySetResult((user.Trim(), password));
return Task.CompletedTask;
}));
return Task.CompletedTask;
}));
_events.Writer.TryWrite(null);
// Si se cancela con Esc no hay respuesta: se da por cancelado a los dos minutos.
_ = Task.Delay(TimeSpan.FromMinutes(2)).ContinueWith(_ => done.TrySetResult(null), TaskScheduler.Default);
return done.Task;
}
/// <summary>Lanza una operacion de red en segundo plano con un mensaje de espera.</summary>
private void Run(string busy, Func<Task> action)
{
_davBusy = busy;
_ = Task.Run(async () =>
{
try
{
await action();
}
catch (DavException e)
{
_davMessage = e.Status == 403 ? Strings.ErrorText(403) : e.Message;
}
catch (Exception e) when (e is HttpRequestException or IOException or UnauthorizedAccessException or TaskCanceledException)
{
_davMessage = e.Message;
}
finally
{
_davBusy = null;
_events.Writer.TryWrite(null);
}
});
}
private IProgress<long> Progress()
{
long total = 0;
string busy = _davBusy ?? "";
return new Progress<long>(n =>
{
total += n;
_davBusy = $"{busy} {FormatSize(total)}";
_events.Writer.TryWrite(null);
});
}
private List<string> ExploreBody(int width, int height)
{
if (_servers is null && !_searching)
StartSearch();
var lines = new List<string>();
if (!Browsing)
{
lines.Add(Terminal.Bold + TuiTexts.ExploreServersTitle + Terminal.Reset);
lines.Add(Frame + new string('─', width) + Terminal.Reset);
if (_searching)
lines.Add(Muted + TuiTexts.ExploreSearching + Terminal.Reset);
else if (_servers is { Count: 0 })
lines.Add(Muted + TuiTexts.ExploreNoneFound + Terminal.Reset);
for (int i = 0; _servers is not null && i < _servers.Count; i++)
{
var s = _servers[i];
string row = $"{(s.IsSunfish ? "◆" : "◇")} {Terminal.Fit(s.Name, 30)} {s.Address}:{s.Port}";
lines.Add(i == _serverSel ? Terminal.Reverse + Terminal.Fit(row, width) + Terminal.Reset : row);
}
AddExploreStatus(lines);
return lines;
}
lines.Add(Terminal.Bold + Accent + _dav!.Server.Authority + Terminal.Reset + _davFolder);
lines.Add(Frame + new string('─', width) + Terminal.Reset);
int room = Math.Max(3, height - 5);
int first = Math.Clamp(_davSel - room / 2, 0, Math.Max(0, _davItems.Count - room));
if (_davFolder != "/")
lines.Add(Muted + "▲ .." + Terminal.Reset);
int nameWidth = Math.Max(10, width - 32);
for (int i = first; i < Math.Min(_davItems.Count, first + room); i++)
{
var e = _davItems[i];
string row = (e.IsCollection ? Accent + "▸ " + Terminal.Reset : " ") + Terminal.Fit(e.Name + (e.IsCollection ? "/" : ""), nameWidth) + " " +
Terminal.Fit(e.IsCollection ? "" : FormatSize(e.Size), 10) + " " +
Terminal.Fit(e.Modified?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "", 16);
lines.Add(i == _davSel ? Terminal.Reverse + Terminal.FitVisible(row, width) + Terminal.Reset : row);
}
if (_davItems.Count == 0 && _davBusy is null)
lines.Add(Muted + Strings.EmptyFolder + Terminal.Reset);
AddExploreStatus(lines);
return lines;
}
private void AddExploreStatus(List<string> lines)
{
lines.Add("");
if (_davBusy is not null)
lines.Add(Accent + "⟳ " + _davBusy + Terminal.Reset);
else if (_davMessage is not null)
lines.Add(Muted + _davMessage + Terminal.Reset);
}
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];
}
}