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:
parent
4248ff2255
commit
47cc904609
3 changed files with 200 additions and 14 deletions
53
NozCli/Repl/NotesCache.cs
Normal file
53
NozCli/Repl/NotesCache.cs
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -145,10 +145,22 @@ public class ReplInput
|
||||||
.Where(n => n.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
|
.Where(n => n.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
|
||||||
.Order().ToArray();
|
.Order().ToArray();
|
||||||
|
|
||||||
var slugs = _ctx.NotesInView().Select(n => n.Slug.Split('/').Last());
|
var cmd = parts[0].ToLowerInvariant();
|
||||||
var folders = _ctx.FoldersInView().Select(f => f + "/");
|
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))
|
.Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase))
|
||||||
.Distinct().Order().ToArray();
|
.Distinct().Order().ToArray();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,87 @@ public static class ReplSession
|
||||||
public static async Task RunAsync(LocalConfig config)
|
public static async Task RunAsync(LocalConfig config)
|
||||||
{
|
{
|
||||||
using var api = new NozApiClient(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!;
|
ServerCapabilities caps = default!;
|
||||||
await AnsiConsole.Status().StartAsync("Connecting…", async _ =>
|
await AnsiConsole.Status().StartAsync("[dim]connecting…[/]", async _ =>
|
||||||
caps = await api.GetConfigAsync());
|
caps = await api.GetConfigAsync(cts.Token));
|
||||||
|
|
||||||
AnsiConsole.MarkupLine($"[bold green]noz[/] [dim]· {caps.Server}[/]");
|
RenderBanner(caps, config.ServerUrl);
|
||||||
AnsiConsole.MarkupLine($"[dim]push={caps.AllowPush} delete={caps.AllowDelete} vault={caps.AllowVault}[/]\n");
|
|
||||||
|
|
||||||
var ctx = new ReplContext(api);
|
var ctx = new ReplContext(api, config);
|
||||||
var registry = new CommandRegistry();
|
var registry = new CommandRegistry();
|
||||||
var input = new ReplInput(registry);
|
var input = new ReplInput(registry);
|
||||||
|
|
||||||
await AnsiConsole.Status().StartAsync("Loading notes…", async _ =>
|
// Cache-first startup: show REPL immediately, refresh in background
|
||||||
ctx.NoteCache = await api.GetNotesAsync());
|
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);
|
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)
|
while (ctx.Running)
|
||||||
{
|
{
|
||||||
var prompt = $"[bold cyan]{ctx.CurrentPath}[/] [dim]»[/] ";
|
using var cmdCts = new CancellationTokenSource();
|
||||||
var line = input.Read(prompt);
|
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);
|
var parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (parts.Length == 0) continue;
|
if (parts.Length == 0) continue;
|
||||||
|
|
@ -54,9 +111,73 @@ public static class ReplSession
|
||||||
continue;
|
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 (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Server error:[/] {ex.Message}"); }
|
||||||
catch (Exception ex) { AnsiConsole.MarkupLine($"[red]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}[/] ");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue