188 lines
6.8 KiB
C#
188 lines
6.8 KiB
C#
using NozCli.Core.Client;
|
|
using NozCli.Core.Config;
|
|
using NozCli.Core.Models;
|
|
using Spectre.Console;
|
|
|
|
namespace NozCli.Repl;
|
|
|
|
public static class ReplSession
|
|
{
|
|
public static async Task RunAsync(LocalConfig config)
|
|
{
|
|
using var api = new NozApiClient(config);
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
// isIdle = true → Ctrl+C exits the REPL gracefully
|
|
// isIdle = false → Ctrl+C cancels only the running command
|
|
var isIdle = false;
|
|
CancellationTokenSource? currentCmdCts = null;
|
|
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
if (isIdle)
|
|
{
|
|
AnsiConsole.MarkupLine("\n[dim]Bye.[/]");
|
|
e.Cancel = false; // let the process exit
|
|
}
|
|
else
|
|
{
|
|
e.Cancel = true; // keep process alive
|
|
currentCmdCts?.Cancel();
|
|
}
|
|
};
|
|
|
|
ServerCapabilities caps = default!;
|
|
await AnsiConsole.Status().StartAsync("[dim]connecting…[/]", async _ =>
|
|
caps = await api.GetConfigAsync(cts.Token));
|
|
|
|
RenderBanner(caps, config.ServerUrl);
|
|
|
|
var ctx = new ReplContext(api, config);
|
|
var registry = new CommandRegistry();
|
|
var input = new ReplInput(registry);
|
|
|
|
// Cache-first startup: show REPL immediately, refresh in background
|
|
var cached = NotesCache.TryLoad();
|
|
if (cached is not null)
|
|
{
|
|
ctx.NoteCache = cached.Notes;
|
|
RenderNoteStats(ctx.NoteCache, cached.LastSynced);
|
|
}
|
|
else
|
|
{
|
|
await AnsiConsole.Status().StartAsync("[dim]loading notes…[/]", async _ =>
|
|
{
|
|
ctx.NoteCache = await api.GetNotesAsync(cts.Token);
|
|
await NotesCache.SaveAsync(ctx.NoteCache);
|
|
});
|
|
RenderNoteStats(ctx.NoteCache, null);
|
|
}
|
|
|
|
input.SetContext(ctx);
|
|
AnsiConsole.MarkupLine("[dim]type [white]help[/] to see commands · [white]exit[/] to quit[/]\n");
|
|
|
|
// Background refresh — always fetch fresh data after REPL is ready
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
var fresh = await api.GetNotesAsync(cts.Token);
|
|
var diff = fresh.Count - ctx.NoteCache.Count;
|
|
ctx.NoteCache = fresh;
|
|
await NotesCache.SaveAsync(fresh);
|
|
|
|
if (diff != 0)
|
|
{
|
|
var sign = diff > 0 ? $"+{diff}" : $"{diff}";
|
|
AnsiConsole.MarkupLine($"\n[dim]↻ notes refreshed ({sign})[/]");
|
|
}
|
|
}
|
|
catch { /* background refresh failure is silent */ }
|
|
}, cts.Token);
|
|
|
|
while (ctx.Running)
|
|
{
|
|
using var cmdCts = new CancellationTokenSource();
|
|
currentCmdCts = cmdCts;
|
|
|
|
var prompt = $"[bold cyan]{ctx.CurrentPath}[/] [dim]»[/] ";
|
|
isIdle = true;
|
|
var line = input.Read(prompt);
|
|
isIdle = false;
|
|
|
|
if (line is null) { AnsiConsole.MarkupLine("[dim]Bye.[/]"); break; }
|
|
|
|
var parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 0) continue;
|
|
|
|
var name = parts[0];
|
|
var args = parts[1..];
|
|
|
|
if (name is "exit" or "quit" or "q")
|
|
{
|
|
AnsiConsole.MarkupLine("[dim]Bye.[/]");
|
|
break;
|
|
}
|
|
|
|
var cmd = registry.Find(name);
|
|
if (cmd is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Unknown command:[/] {name} [dim](type 'help')[/]");
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
await cmd.ExecuteAsync(args, ctx, cmdCts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
|
|
}
|
|
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Unauthorized.[/] Run [cyan]noz init <url> <token>[/] to update your token.");
|
|
}
|
|
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Forbidden.[/] This operation is disabled on the server (check noosphere.toml).");
|
|
}
|
|
catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Server error:[/] {ex.Message}"); }
|
|
catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); }
|
|
}
|
|
}
|
|
|
|
private static void RenderBanner(ServerCapabilities caps, string serverUrl)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Orbital logo — nucleus (accent) + tilted orbit ring
|
|
AnsiConsole.MarkupLine(" [dim]· ─ ·[/]");
|
|
AnsiConsole.MarkupLine(" [dim]·[/] [dim]·[/]");
|
|
AnsiConsole.MarkupLine(" [dim]·[/] [bold dodgerblue2]◉[/] [dim]·[/] [bold cornflowerblue]noz[/]");
|
|
AnsiConsole.MarkupLine(" [dim]·[/] [dim]·[/]");
|
|
AnsiConsole.MarkupLine(" [dim]· ─ ·[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var host = Uri.TryCreate(serverUrl, UriKind.Absolute, out var u) ? u.Host : serverUrl;
|
|
var ver = VersionInfo.Full;
|
|
|
|
var capGrid = new Grid().AddColumns(3);
|
|
capGrid.AddRow(
|
|
Cap("push", caps.AllowPush),
|
|
Cap("delete", caps.AllowDelete),
|
|
Cap("vault", caps.AllowVault)
|
|
);
|
|
|
|
var panel = new Panel(capGrid)
|
|
{
|
|
Header = new PanelHeader($"[dim] {host} · v{ver} [/]"),
|
|
Border = BoxBorder.Rounded,
|
|
BorderStyle = new Style(new Color(80, 80, 100)),
|
|
Padding = new Padding(1, 0),
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
}
|
|
|
|
private static void RenderNoteStats(List<NoteInfo> notes, DateTimeOffset? lastSynced)
|
|
{
|
|
var seed = notes.Count(n => n.Status == "seed");
|
|
var budding = notes.Count(n => n.Status == "budding");
|
|
var evergreen = notes.Count(n => n.Status == "evergreen");
|
|
|
|
var age = lastSynced is null ? "" : $" · [dim grey]{NotesCache.TimeAgo(lastSynced.Value)}[/]";
|
|
var rule = new Rule($"[dim] {notes.Count} notes · [yellow]◦ {seed}[/] [magenta]◈ {budding}[/] [cyan]◉ {evergreen}[/]{age} [/]")
|
|
{
|
|
Style = new Style(new Color(60, 60, 80)),
|
|
Justification = Justify.Left,
|
|
};
|
|
AnsiConsole.Write(rule);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
private static Markup Cap(string label, bool on) =>
|
|
new(on
|
|
? $"[green]✓[/] [dim]{label}[/] "
|
|
: $"[dim red]✗[/] [dim]{label}[/] ");
|
|
}
|