From 47cc9046098b28cc6d68306ba9fa3ab6779c1232 Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sat, 16 May 2026 23:20:39 +0200 Subject: [PATCH] feat(repl): Visual banner, JSON note cache, background refresh, Ctrl+C handling - Startup banner: FigletText + capabilities panel + note stats with status dots - JSON cache at ~/.cache/noz/notes.json for instant startup - Cache-first loading: show REPL immediately, refresh notes in background - Timestamp in stats line (e.g. 'vor 5 Min.') for data freshness feedback - Ctrl+C in idle exits REPL gracefully; during command cancels only the request - Per-command CancellationTokenSource so cancel does not kill the session - Tab completion: command-aware (cd only suggests folders) --- NozCli/Repl/NotesCache.cs | 53 ++++++++++++++ NozCli/Repl/ReplInput.cs | 16 +++- NozCli/Repl/ReplSession.cs | 145 ++++++++++++++++++++++++++++++++++--- 3 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 NozCli/Repl/NotesCache.cs diff --git a/NozCli/Repl/NotesCache.cs b/NozCli/Repl/NotesCache.cs new file mode 100644 index 0000000..55e7dca --- /dev/null +++ b/NozCli/Repl/NotesCache.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using NozCli.Core.Models; +using NozCli.Core.Serialization; + +namespace NozCli.Repl; + +public static class NotesCache +{ + private static string CachePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cache", "noz", "notes.json"); + + public static NotesCacheFile? TryLoad() + { + try + { + if (!File.Exists(CachePath)) return null; + var json = File.ReadAllText(CachePath); + return JsonSerializer.Deserialize(json, NozJsonContext.Default.NotesCacheFile); + } + catch { return null; } + } + + public static async Task SaveAsync(List notes) + { + try + { + var dir = Path.GetDirectoryName(CachePath)!; + Directory.CreateDirectory(dir); + + var cache = new NotesCacheFile(DateTimeOffset.UtcNow, notes); + var json = JsonSerializer.Serialize(cache, NozJsonContext.Default.NotesCacheFile); + + // Atomic write: tmp → rename + var tmp = CachePath + ".tmp"; + await File.WriteAllTextAsync(tmp, json); + File.Move(tmp, CachePath, overwrite: true); + } + catch { /* cache write failures are non-fatal */ } + } + + public static string TimeAgo(DateTimeOffset dt) + { + var diff = DateTimeOffset.UtcNow - dt; + return diff.TotalSeconds switch + { + < 60 => "gerade eben", + < 3600 => $"vor {(int)diff.TotalMinutes} Min.", + < 86400 => $"vor {(int)diff.TotalHours} Std.", + _ => $"vor {(int)diff.TotalDays} Tagen", + }; + } +} diff --git a/NozCli/Repl/ReplInput.cs b/NozCli/Repl/ReplInput.cs index eae435a..58520e2 100644 --- a/NozCli/Repl/ReplInput.cs +++ b/NozCli/Repl/ReplInput.cs @@ -145,10 +145,22 @@ public class ReplInput .Where(n => n.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) .Order().ToArray(); - var slugs = _ctx.NotesInView().Select(n => n.Slug.Split('/').Last()); + var cmd = parts[0].ToLowerInvariant(); var folders = _ctx.FoldersInView().Select(f => f + "/"); - return slugs.Concat(folders) + // cd only navigates into folders, never note slugs + if (cmd is "cd") + return folders + .Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) + .Distinct().Order().ToArray(); + + // For note commands, suggest notes visible at current depth (relative names) + var depth = _ctx.CurrentSubPath is null ? 0 : _ctx.CurrentSubPath.Split('/').Length; + var notes = _ctx.NotesInView() + .Where(n => _ctx.SlugRelative(n.Slug).Split('/').Length == depth + 1) + .Select(n => _ctx.SlugRelative(n.Slug).Split('/')[depth]); + + return notes.Concat(folders) .Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) .Distinct().Order().ToArray(); } diff --git a/NozCli/Repl/ReplSession.cs b/NozCli/Repl/ReplSession.cs index e6a3288..35bdae6 100644 --- a/NozCli/Repl/ReplSession.cs +++ b/NozCli/Repl/ReplSession.cs @@ -10,30 +10,87 @@ 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("Connecting…", async _ => - caps = await api.GetConfigAsync()); + await AnsiConsole.Status().StartAsync("[dim]connecting…[/]", async _ => + caps = await api.GetConfigAsync(cts.Token)); - AnsiConsole.MarkupLine($"[bold green]noz[/] [dim]· {caps.Server}[/]"); - AnsiConsole.MarkupLine($"[dim]push={caps.AllowPush} delete={caps.AllowDelete} vault={caps.AllowVault}[/]\n"); + RenderBanner(caps, config.ServerUrl); - var ctx = new ReplContext(api); + var ctx = new ReplContext(api, config); var registry = new CommandRegistry(); var input = new ReplInput(registry); - await AnsiConsole.Status().StartAsync("Loading notes…", async _ => - ctx.NoteCache = await api.GetNotesAsync()); + // 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); + } - AnsiConsole.MarkupLine($"[dim]● {ctx.NoteCache.Count} notes cached[/]\n"); 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) { - var prompt = $"[bold cyan]{ctx.CurrentPath}[/] [dim]»[/] "; - var line = input.Read(prompt); + using var cmdCts = new CancellationTokenSource(); + currentCmdCts = cmdCts; - if (line is null) break; // Ctrl-C / Ctrl-D + 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; @@ -54,9 +111,73 @@ public static class ReplSession continue; } - try { await cmd.ExecuteAsync(args, ctx); } + 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 [/] 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(); + AnsiConsole.Write(new FigletText("noz") + .LeftJustified() + .Color(new Color(99, 179, 237))); // sky-400 + + 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 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}[/] "); }