From ae8a961cfda03cb1ed0b51649ed7890b783529e3 Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sat, 16 May 2026 23:20:59 +0200 Subject: [PATCH] feat(commands): Dual-editor (nano/code), new note, parallel sync pull - edit: terminal editor (nano/vim) by default, -c flag opens VS Code/Zed - edit/new: ResolveTerminal() + ResolveCode() auto-detect editors on PATH - new: create note from template, opens editor, confirms before push - sync: pull notes from server with Parallel.ForEachAsync (8 concurrent) - sync: hash diff - only download notes changed since last sync - sync --dry-run: preview without writing, --all to force full download - push: hash diff against server cache, Progress bar, --all flag --- NozCli/Commands/EditCommand.cs | 88 ++++++++++++--------- NozCli/Commands/NewCommand.cs | 90 ++++++++++++++++++++++ NozCli/Commands/PushCommand.cs | 6 +- NozCli/Commands/SyncCommand.cs | 135 ++++++++++++++++++++++++++++++--- 4 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 NozCli/Commands/NewCommand.cs diff --git a/NozCli/Commands/EditCommand.cs b/NozCli/Commands/EditCommand.cs index 1d566b1..23c5e0d 100644 --- a/NozCli/Commands/EditCommand.cs +++ b/NozCli/Commands/EditCommand.cs @@ -9,40 +9,40 @@ namespace NozCli.Commands; public class EditCommand : ICommand { public string Name => "edit"; - public string Description => "Open a note in your default editor, then offer to push"; - public string Usage => "edit "; + public string Description => "Open a note in nano (or -c for VS Code)"; + public string Usage => "edit [-c] "; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - var slug = args.FirstOrDefault(); - if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit [/]"); return; } + var useCode = args.Contains("-c") || args.Contains("--code"); + var slug = args.FirstOrDefault(a => !a.StartsWith('-')); + + if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit [-c] [/]"); return; } slug = ctx.ResolveSlug(slug); - // 1. Fetch note from server NozCli.Core.Models.NoteContent note = default!; await AnsiConsole.Status().StartAsync("Fetching…", async _ => - note = await ctx.Api.GetNoteAsync(slug)); + note = await ctx.Api.GetNoteAsync(slug, ct)); - // 2. Write to temp file var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md"); await File.WriteAllTextAsync(tmp, note.Markdown); - // 3. Open editor - var editor = ResolveEditor(); + var (editor, editorArgs) = useCode + ? ResolveCode(tmp, ctx.Config.CodeEditor) + : ResolveTerminal(tmp, ctx.Config.Editor); + AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); var proc = Process.Start(new ProcessStartInfo { - FileName = editor, - Arguments = $"\"{tmp}\"", - UseShellExecute = !OperatingSystem.IsWindows(), + FileName = editor, + Arguments = editorArgs, + UseShellExecute = false, }); proc?.WaitForExit(); - // 4. Compare hashes - var edited = await File.ReadAllTextAsync(tmp); - var newHash = HashContent(edited); - + var edited = await File.ReadAllTextAsync(tmp); + var newHash = Sha256(edited); File.Delete(tmp); if (newHash == note.Hash) @@ -51,47 +51,61 @@ public class EditCommand : ICommand return; } - // 5. Ask to push AnsiConsole.MarkupLine("[green]Changes detected.[/]"); var push = AnsiConsole.Confirm("Push changes to server?"); if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; } await AnsiConsole.Status().StartAsync("Pushing…", async _ => - await ctx.Api.PutNoteAsync(slug, edited)); + { + await ctx.Api.PutNoteAsync(slug, edited, ct); + ctx.NoteCache = await ctx.Api.GetNotesAsync(ct); + }); AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated"); } - private static string ResolveEditor() + // nano (or configured terminal editor) — quick edits + internal static (string, string) ResolveTerminal(string filePath, string? configured) { - // Priority: $EDITOR → $VISUAL → VS Code → nano → vi - var env = Environment.GetEnvironmentVariable("EDITOR") - ?? Environment.GetEnvironmentVariable("VISUAL"); - if (env is not null) return env; - - foreach (var candidate in new[] { "code", "nano", "vi", "notepad" }) - if (IsOnPath(candidate)) return candidate; - - return "nano"; + var editor = configured + ?? Environment.GetEnvironmentVariable("EDITOR") + ?? (IsOnPath("nano") ? "nano" : IsOnPath("vi") ? "vi" : "nano"); + return (editor, $"\"{filePath}\""); } - private static bool IsOnPath(string name) + // -c editor: configured, or auto-detect code/zed/etc. + // GUI editors that need a --wait flag are detected by name. + internal static (string, string) ResolveCode(string filePath, string? configured) + { + var editor = configured + ?? (IsOnPath("code") ? "code" : + IsOnPath("code-insiders")? "code-insiders": + IsOnPath("zed") ? "zed" : + IsOnPath("subl") ? "subl" : "code"); + + // These editors need --wait to block until the file is closed + var needsWait = editor is "code" or "code-insiders" or "subl"; + var fileArg = $"\"{filePath}\""; + return (editor, needsWait ? $"--wait {fileArg}" : fileArg); + } + + internal static bool IsOnPath(string name) { try { - var result = Process.Start(new ProcessStartInfo + using var p = Process.Start(new ProcessStartInfo { - FileName = OperatingSystem.IsWindows() ? "where" : "which", - Arguments = name, + FileName = OperatingSystem.IsWindows() ? "where" : "which", + Arguments = name, RedirectStandardOutput = true, - UseShellExecute = false, + UseShellExecute = false, }); - result?.WaitForExit(); - return result?.ExitCode == 0; + p?.WaitForExit(); + return p?.ExitCode == 0; } catch { return false; } } - private static string HashContent(string content) => + private static string Sha256(string content) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower(); } diff --git a/NozCli/Commands/NewCommand.cs b/NozCli/Commands/NewCommand.cs new file mode 100644 index 0000000..0205a5b --- /dev/null +++ b/NozCli/Commands/NewCommand.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using NozCli.Core.Models; +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class NewCommand : ICommand +{ + public string Name => "new"; + public string Description => "Create a new note and open it in nano (or -c for VS Code)"; + public string Usage => "new [-c] "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) + { + var useCode = args.Contains("-c") || args.Contains("--code"); + var nameArg = args.FirstOrDefault(a => !a.StartsWith('-')); + if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [/]"); return; } + + var slug = ctx.ResolveNewSlug(nameArg); + + if (ctx.NoteCache.Any(n => n.Slug == slug)) + { + AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]"); + return; + } + + var topic = ctx.CurrentTopic ?? (slug.Contains('/') ? slug.Split('/')[0] : slug); + var title = ToTitle(slug.Split('/').Last()); + var lang = "de"; + + var template = $""" + --- + title: "{title}" + description: "" + topic: {topic} + status: seed + lang: {lang} + --- + + # {title} + + """; + + var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md"); + await File.WriteAllTextAsync(tmp, template); + + var (editor, editorArgs) = useCode + ? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor) + : EditCommand.ResolveTerminal(tmp, ctx.Config.Editor); + + AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); + var proc = Process.Start(new ProcessStartInfo + { + FileName = editor, + Arguments = editorArgs, + UseShellExecute = false, + }); + proc?.WaitForExit(); + + var markdown = await File.ReadAllTextAsync(tmp); + File.Delete(tmp); + + if (markdown.Trim() == template.Trim()) + { + AnsiConsole.MarkupLine("[dim]No changes — note not created.[/]"); + return; + } + + var push = AnsiConsole.Confirm($"Push [cyan]{slug}[/] to server?"); + if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; } + + await AnsiConsole.Status().StartAsync("Creating…", async _ => + { + await ctx.Api.PutNoteAsync(slug, markdown, ct); + ctx.NoteCache = await ctx.Api.GetNotesAsync(ct); + }); + + AnsiConsole.MarkupLine($"[green]✓[/] {slug} created"); + } + + private static string ToTitle(string slug) => + string.Join(' ', slug.Split('-').Select(w => + w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w)); + + private static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(); +} diff --git a/NozCli/Commands/PushCommand.cs b/NozCli/Commands/PushCommand.cs index 0f5dbe4..ccec8e1 100644 --- a/NozCli/Commands/PushCommand.cs +++ b/NozCli/Commands/PushCommand.cs @@ -10,7 +10,7 @@ public class PushCommand : ICommand public string Description => "Push local markdown files to the server"; public string Usage => "push [--all]"; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { var localPath = args.FirstOrDefault(a => !a.StartsWith("--")); var force = args.Contains("--all"); @@ -66,7 +66,7 @@ public class PushCommand : ICommand try { - await ctx.Api.PutNoteAsync(slug, markdown); + await ctx.Api.PutNoteAsync(slug, markdown, ct); pushed++; } catch (Exception ex) @@ -82,6 +82,6 @@ public class PushCommand : ICommand AnsiConsole.MarkupLine($"[green]↑ {pushed} pushed[/] [dim]{skipped} unchanged {(failed > 0 ? $"[red]{failed} failed[/]" : "")}[/]"); // Refresh note cache - ctx.NoteCache = await ctx.Api.GetNotesAsync(); + ctx.NoteCache = await ctx.Api.GetNotesAsync(ct); } } diff --git a/NozCli/Commands/SyncCommand.cs b/NozCli/Commands/SyncCommand.cs index 9380ff9..cfa0050 100644 --- a/NozCli/Commands/SyncCommand.cs +++ b/NozCli/Commands/SyncCommand.cs @@ -1,24 +1,141 @@ +using NozCli.Core.Client; +using NozCli.Core.Models; using NozCli.Repl; using Spectre.Console; namespace NozCli.Commands; -/// sync = push + rebuild index +/// sync — pulls notes from the server into a local directory. +/// Only downloads notes whose server hash differs from the local file hash. +/// Uses Parallel.ForEachAsync (8 concurrent) for fast bulk downloads. public class SyncCommand : ICommand { public string Name => "sync"; - public string Description => "Push notes and rebuild the server index"; - public string Usage => "sync "; + public string Description => "Pull notes from server to a local directory (hash-diffed)"; + public string Usage => "sync [--all] [--dry-run]"; - private readonly PushCommand _push = new(); + private const int Concurrency = 8; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - await _push.ExecuteAsync(args, ctx); + var localPath = args.FirstOrDefault(a => !a.StartsWith("--")); + var force = args.Contains("--all"); + var dryRun = args.Contains("--dry-run"); - await AnsiConsole.Status().StartAsync("Triggering index rebuild…", async _ => - await ctx.Api.RebuildIndexAsync()); + if (localPath is null) + { + AnsiConsole.MarkupLine("[red]Usage: sync [--all] [--dry-run][/]"); + return; + } - AnsiConsole.MarkupLine("[green]✓ Index rebuild queued[/]"); + localPath = Path.GetFullPath(localPath); + + // Determine which notes to sync (respect current topic/subpath) + var candidates = ctx.CurrentTopic is null + ? ctx.NoteCache + : ctx.NotesInView().ToList(); + + if (candidates is IEnumerable enumerable) + candidates = enumerable.ToList(); + + var noteList = (List)candidates; + + if (noteList.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No notes in current view.[/]"); + return; + } + + // Diff: which notes actually need downloading? + var toDownload = new List(); + + foreach (var note in noteList) + { + var filePath = SlugToPath(localPath, note.Slug); + + if (!force && note.Hash is not null && File.Exists(filePath)) + { + var localContent = await File.ReadAllTextAsync(filePath, ct); + var localHash = HashHelper.Sha256(localContent); + if (localHash == note.Hash) continue; + } + + toDownload.Add(note); + } + + var unchanged = noteList.Count - toDownload.Count; + + if (toDownload.Count == 0) + { + AnsiConsole.MarkupLine($"[green]✓ All {noteList.Count} notes up to date.[/]"); + return; + } + + if (dryRun) + { + AnsiConsole.MarkupLine($"[dim]Dry run — would download {toDownload.Count} notes, {unchanged} unchanged.[/]"); + foreach (var n in toDownload) + AnsiConsole.MarkupLine($" [dim]↓[/] {n.Slug}"); + return; + } + + AnsiConsole.MarkupLine($"[dim]Downloading {toDownload.Count} notes · {unchanged} unchanged · concurrency {Concurrency}[/]"); + AnsiConsole.WriteLine(); + + int downloaded = 0, failed = 0; + var failedSlugs = new System.Collections.Concurrent.ConcurrentBag(); + var lockObj = new object(); + + await AnsiConsole.Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new SpinnerColumn()) + .StartAsync(async progress => + { + var task = progress.AddTask($"[cyan]↓ syncing[/]", maxValue: toDownload.Count); + + await Parallel.ForEachAsync( + toDownload, + new ParallelOptions { MaxDegreeOfParallelism = Concurrency, CancellationToken = ct }, + async (note, innerCt) => + { + try + { + var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt); + var filePath = SlugToPath(localPath, note.Slug); + + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllTextAsync(filePath, content.Markdown, innerCt); + + lock (lockObj) downloaded++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failedSlugs.Add(note.Slug); + lock (lockObj) failed++; + } + finally + { + task.Increment(1); + } + }); + }); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[green]↓ {downloaded} downloaded[/] " + + $"[dim]{unchanged} unchanged[/]" + + (failed > 0 ? $" [red]{failed} failed[/]" : "")); + + if (failed > 0) + { + foreach (var slug in failedSlugs.Order()) + AnsiConsole.MarkupLine($" [red]✗[/] {slug}"); + } } + + private static string SlugToPath(string baseDir, string slug) => + Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md"); }