noz-cli/NozCli/Commands/EditCommand.cs
kryptomrx ae8a961cfd 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
2026-05-16 23:20:59 +02:00

111 lines
4 KiB
C#

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class EditCommand : ICommand
{
public string Name => "edit";
public string Description => "Open a note in nano (or -c for VS Code)";
public string Usage => "edit [-c] <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
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] <slug>[/]"); return; }
slug = ctx.ResolveSlug(slug);
NozCli.Core.Models.NoteContent note = default!;
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
note = await ctx.Api.GetNoteAsync(slug, ct));
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
await File.WriteAllTextAsync(tmp, note.Markdown);
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 = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
var edited = await File.ReadAllTextAsync(tmp);
var newHash = Sha256(edited);
File.Delete(tmp);
if (newHash == note.Hash)
{
AnsiConsole.MarkupLine("[dim]No changes.[/]");
return;
}
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, ct);
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
});
AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated");
}
// nano (or configured terminal editor) — quick edits
internal static (string, string) ResolveTerminal(string filePath, string? configured)
{
var editor = configured
?? Environment.GetEnvironmentVariable("EDITOR")
?? (IsOnPath("nano") ? "nano" : IsOnPath("vi") ? "vi" : "nano");
return (editor, $"\"{filePath}\"");
}
// -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
{
using var p = Process.Start(new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "where" : "which",
Arguments = name,
RedirectStandardOutput = true,
UseShellExecute = false,
});
p?.WaitForExit();
return p?.ExitCode == 0;
}
catch { return false; }
}
private static string Sha256(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower();
}