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)
This commit is contained in:
kryptomrx 2026-05-16 23:20:39 +02:00
parent 4248ff2255
commit 47cc904609
3 changed files with 200 additions and 14 deletions

53
NozCli/Repl/NotesCache.cs Normal file
View file

@ -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<NoteInfo> 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",
};
}
}

View file

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

View file

@ -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 <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();
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<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}[/] ");
}