diff --git a/NozCli/Commands/CatCommand.cs b/NozCli/Commands/CatCommand.cs index f5ff990..898b018 100644 --- a/NozCli/Commands/CatCommand.cs +++ b/NozCli/Commands/CatCommand.cs @@ -9,17 +9,17 @@ public class CatCommand : ICommand public string Description => "Print the raw markdown of a note"; public string Usage => "cat "; - 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: cat [/]"); return; } slug = ctx.ResolveSlug(slug); + Core.Models.NoteContent note = default!; await AnsiConsole.Status().StartAsync("Fetching…", async _ => - { - var note = await ctx.Api.GetNoteAsync(slug); - AnsiConsole.WriteLine(note.Markdown); - }); + note = await ctx.Api.GetNoteAsync(slug, ct)); + + AnsiConsole.WriteLine(note.Markdown); } } diff --git a/NozCli/Commands/CdCommand.cs b/NozCli/Commands/CdCommand.cs index ba99978..cf18bf5 100644 --- a/NozCli/Commands/CdCommand.cs +++ b/NozCli/Commands/CdCommand.cs @@ -9,40 +9,26 @@ public class CdCommand : ICommand public string Description => "Navigate into a topic or folder"; public string Usage => "cd | cd .. | cd ~"; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - var target = args.FirstOrDefault(); + var target = args.FirstOrDefault()?.TrimEnd('/'); if (target is null or "~") { - ctx.CurrentPath = "~"; - return; + ctx.CurrentTopic = null; + ctx.CurrentSubPath = null; + return Task.CompletedTask; } if (target == "..") { - var parts = ctx.CurrentPath.TrimEnd('/').Split('/'); - ctx.CurrentPath = parts.Length > 1 - ? string.Join("/", parts[..^1]) - : "~"; - return; + ctx.NavigateUp(); + return Task.CompletedTask; } - var candidate = ctx.CurrentPath == "~" - ? $"~/noosphere/{target}" - : $"{ctx.CurrentPath}/{target}"; - - // Check the path has at least one note under it - var prefix = candidate.Replace("~/noosphere/", "").Replace("~/noosphere", ""); - var exists = ctx.NoteCache.Any(n => n.Slug.StartsWith(prefix + "/") || n.Slug == prefix); - - if (!exists) - { + if (!ctx.NavigateTo(target)) AnsiConsole.MarkupLine($"[red]cd: {target}: no such folder[/]"); - return; - } - ctx.CurrentPath = candidate; - await Task.CompletedTask; + return Task.CompletedTask; } } diff --git a/NozCli/Commands/HelpCommand.cs b/NozCli/Commands/HelpCommand.cs index f75fabe..94d708f 100644 --- a/NozCli/Commands/HelpCommand.cs +++ b/NozCli/Commands/HelpCommand.cs @@ -13,7 +13,7 @@ public class HelpCommand : ICommand public HelpCommand(IReadOnlyList commands) => _commands = commands; - public Task ExecuteAsync(string[] args, ReplContext ctx) + public Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { var table = new Table().BorderStyle(Style.Plain).HideHeaders(); table.AddColumns("cmd", "desc"); diff --git a/NozCli/Commands/ICommand.cs b/NozCli/Commands/ICommand.cs index 2208a18..e492afa 100644 --- a/NozCli/Commands/ICommand.cs +++ b/NozCli/Commands/ICommand.cs @@ -8,5 +8,5 @@ public interface ICommand string Description { get; } string Usage { get; } - Task ExecuteAsync(string[] args, ReplContext ctx); + Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default); } diff --git a/NozCli/Commands/LsCommand.cs b/NozCli/Commands/LsCommand.cs index b2f0916..e350ff9 100644 --- a/NozCli/Commands/LsCommand.cs +++ b/NozCli/Commands/LsCommand.cs @@ -1,3 +1,4 @@ +using NozCli.Core.Models; using NozCli.Repl; using Spectre.Console; @@ -7,73 +8,163 @@ public class LsCommand : ICommand { public string Name => "ls"; public string Description => "List notes and folders at current path"; - public string Usage => "ls [-l]"; + public string Usage => "ls [-la]"; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - var detailed = args.Contains("-l"); - var folders = ctx.FoldersInView().OrderBy(f => f).ToList(); - var prefix = ctx.PathPrefix(); + var flags = string.Concat(args.Where(a => a.StartsWith('-')).SelectMany(a => a.TrimStart('-'))); + var detailed = flags.Contains('l'); + var depth = ctx.CurrentSubPath is null ? 0 : ctx.CurrentSubPath.Split('/').Length; - // Determine which segments are folders (have children) vs direct notes - var directNotes = ctx.NotesInView() - .Where(n => - { - var depth = prefix is null ? 0 : prefix.Split('/').Length; - return n.Slug.Split('/').Length == depth + 1; - }) - .OrderBy(n => n.Title) - .ToList(); - - var subFolders = ctx.FoldersInView() - .Where(f => directNotes.All(n => n.Slug.Split('/').Last() != f)) - .OrderBy(f => f) - .ToList(); + var (subFolders, directNotes) = Partition(ctx, depth); if (!detailed) { - foreach (var folder in subFolders) - AnsiConsole.MarkupLine($" [blue]{folder}/[/]"); - foreach (var note in directNotes) - AnsiConsole.MarkupLine($" [white]{note.Slug.Split('/').Last()}[/]"); + RenderShort(ctx, subFolders, directNotes, depth); return; } - var table = new Table().BorderStyle(Style.Plain).HideHeaders(); - table.AddColumns("", "name", "status", "lang", "title"); - - foreach (var folder in subFolders) - { - var count = ctx.NotesInView() - .Count(n => n.Slug.StartsWith((prefix is null ? "" : prefix + "/") + folder + "/")); - table.AddRow( - "[blue]πŸ“[/]", - $"[blue]{folder}/[/]", - $"[dim]{count} notes[/]", - "", - "" - ); - } - - foreach (var note in directNotes) - { - var statusColor = note.Status switch - { - "seed" => "yellow", - "budding" => "purple", - "evergreen" => "teal", - _ => "grey" - }; - table.AddRow( - "●", - $"[white]{note.Slug.Split('/').Last()}[/]", - $"[{statusColor}]{note.Status}[/]", - $"[dim]{note.Lang}[/]", - $"[dim]{note.Title}[/]" - ); - } - - AnsiConsole.Write(table); + RenderLong(ctx, subFolders, directNotes, depth); await Task.CompletedTask; } + + // ── Short view ─────────────────────────────────────────────────────────────── + + private static void RenderShort(ReplContext ctx, List folders, List notes, int depth) + { + AnsiConsole.WriteLine(); + foreach (var f in folders) + AnsiConsole.MarkupLine($" [grey46]β–Έ[/] [white]{Markup.Escape(f)}/[/]"); + + if (folders.Count > 0 && notes.Count > 0) + AnsiConsole.WriteLine(); + + foreach (var n in notes) + { + var (color, dot) = Style(n.Status); + var name = ctx.SlugRelative(n.Slug).Split('/')[depth]; + AnsiConsole.MarkupLine($" [{color}]{dot}[/] [white]{Markup.Escape(name)}[/]"); + } + AnsiConsole.WriteLine(); + } + + // ── Long view ──────────────────────────────────────────────────────────────── + + private static void RenderLong(ReplContext ctx, List folders, List notes, int depth) + { + var term = Math.Max(Console.WindowWidth, 72); + var nameCol = Math.Clamp(term / 3, 20, 36); + var titleCol = Math.Max(16, term - nameCol - 32); + + var table = new Table() + .BorderStyle(new Style(new Color(50, 50, 65))) + .Border(TableBorder.Simple) + .Expand() + .AddColumn(new TableColumn("[dim] [/]") { Width = 3, NoWrap = true }) + .AddColumn(new TableColumn("[dim grey]name[/]") { Width = nameCol, NoWrap = true }) + .AddColumn(new TableColumn("[dim grey]status[/]") { Width = 10, NoWrap = true }) + .AddColumn(new TableColumn("[dim grey]lang[/]") { Width = 5, NoWrap = true }) + .AddColumn(new TableColumn("[dim grey]date[/]") { Width = 12, NoWrap = true }) + .AddColumn(new TableColumn("[dim grey]title[/]") { NoWrap = true }); + + foreach (var folder in folders) + { + var folderNotes = ctx.CurrentTopic is null + ? ctx.NoteCache.Where(n => string.Equals(n.Topic, folder, StringComparison.OrdinalIgnoreCase)).ToList() + : ctx.NotesInView().Where(n => + { + var rel = ctx.SlugRelative(n.Slug); + return rel.StartsWith(folder + "/") || rel == folder; + }).ToList(); + + var count = folderNotes.Count; + var countStr = $"{count} {(count == 1 ? "note" : "notes")}"; + var minibar = string.Concat(folderNotes.Select(n => + { + var (c, d) = Style(n.Status); + return $"[{c} dim]{d}[/]"; + })); + + table.AddRow( + "[grey46]β–Έ[/]", + $"[bold white]{Markup.Escape(Truncate(folder, nameCol - 1))}/[/]", + $"[dim]{countStr}[/]", + "", "", + minibar + ); + } + + if (folders.Count > 0 && notes.Count > 0) + table.AddEmptyRow(); + + foreach (var note in notes) + { + var (color, dot) = Style(note.Status); + var name = Truncate(ctx.SlugRelative(note.Slug).Split('/')[depth], nameCol); + var title = Truncate(note.Title, titleCol); + var date = FormatDate(note.Date); + + table.AddRow( + $"[{color}]{dot}[/]", + $"[white]{Markup.Escape(name)}[/]", + $"[{color} dim]{note.Status}[/]", + $"[dim]{note.Lang}[/]", + $"[dim grey]{date}[/]", + $"[dim]{Markup.Escape(title)}[/]" + ); + } + + AnsiConsole.WriteLine(); + AnsiConsole.Write(table); + + var total = folders.Count + notes.Count; + AnsiConsole.MarkupLine($"[dim grey] {total} {(total == 1 ? "item" : "items")}[/]\n"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static (List folders, List notes) Partition(ReplContext ctx, int depth) + { + if (ctx.CurrentTopic is null) + return (ctx.FoldersInView().OrderBy(f => f).ToList(), []); + + var inView = ctx.NotesInView().ToList(); + + var notes = inView + .Where(n => ctx.SlugRelative(n.Slug).Split('/').Length == depth + 1) + .OrderBy(n => ctx.SlugRelative(n.Slug).Split('/')[depth]) + .ToList(); + + var noteSlugs = notes + .Select(n => ctx.SlugRelative(n.Slug).Split('/')[depth]) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var folders = inView + .Where(n => ctx.SlugRelative(n.Slug).Split('/').Length > depth + 1) + .Select(n => ctx.SlugRelative(n.Slug).Split('/')[depth]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(seg => !noteSlugs.Contains(seg)) + .OrderBy(f => f) + .ToList(); + + return (folders, notes); + } + + private static (string color, string dot) Style(string status) => status switch + { + "seed" => ("yellow", "β—¦"), + "budding" => ("magenta", "β—ˆ"), + "evergreen" => ("cyan", "β—‰"), + _ => ("grey", "β—‹"), + }; + + private static string FormatDate(string? date) + { + if (date is null) return "β€”"; + if (DateTime.TryParse(date, out var d)) return d.ToString("yyyy-MM-dd"); + return date.Length > 10 ? date[..10] : date; + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..(max - 1)] + "…"; } diff --git a/NozCli/Commands/RmCommand.cs b/NozCli/Commands/RmCommand.cs index 8a63002..83b091a 100644 --- a/NozCli/Commands/RmCommand.cs +++ b/NozCli/Commands/RmCommand.cs @@ -9,7 +9,7 @@ public class RmCommand : ICommand public string Description => "Delete a note from the server"; public string Usage => "rm "; - 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: rm [/]"); return; } @@ -20,7 +20,7 @@ public class RmCommand : ICommand return; await AnsiConsole.Status().StartAsync("Deleting…", async _ => - await ctx.Api.DeleteNoteAsync(slug)); + await ctx.Api.DeleteNoteAsync(slug, ct)); ctx.NoteCache.RemoveAll(n => n.Slug == slug); AnsiConsole.MarkupLine($"[green]βœ“[/] {slug} deleted"); diff --git a/NozCli/Commands/StatusCommand.cs b/NozCli/Commands/StatusCommand.cs index fc7401d..1adf075 100644 --- a/NozCli/Commands/StatusCommand.cs +++ b/NozCli/Commands/StatusCommand.cs @@ -11,7 +11,7 @@ public class StatusCommand : ICommand public string Description => "Show diff between a local folder and the server"; public string Usage => "status "; - public async Task ExecuteAsync(string[] args, ReplContext ctx) + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { var localPath = args.FirstOrDefault(); if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: status [/]"); return; } diff --git a/NozCli/Commands/TreeCommand.cs b/NozCli/Commands/TreeCommand.cs new file mode 100644 index 0000000..9879263 --- /dev/null +++ b/NozCli/Commands/TreeCommand.cs @@ -0,0 +1,154 @@ +using NozCli.Core.Models; +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class TreeCommand : ICommand +{ + public string Name => "tree"; + public string Description => "Show notes hierarchy as a tree"; + public string Usage => "tree"; + + public Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) + { + AnsiConsole.WriteLine(); + + if (ctx.CurrentTopic is null) + RenderRoot(ctx); + else + RenderTopic(ctx); + + AnsiConsole.WriteLine(); + return Task.CompletedTask; + } + + // ── Root view: one branch per topic ───────────────────────────────────────── + + private static void RenderRoot(ReplContext ctx) + { + var root = new Tree($"[bold white]~[/]") + .Style(new Style(new Color(80, 80, 100))); + + var topics = ctx.NoteCache + .Select(n => n.Topic) + .Where(t => !string.IsNullOrEmpty(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToList(); + + foreach (var topic in topics) + { + var topicNotes = ctx.NoteCache + .Where(n => string.Equals(n.Topic, topic, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var count = topicNotes.Count; + var countStr = $"[dim grey]{count} {(count == 1 ? "note" : "notes")}[/]"; + var topicNode = root.AddNode($"[grey46]β–Έ[/] [bold white]{Markup.Escape(topic)}/[/] {countStr}"); + + AddNotesRecursive(topicNode, topicNotes, topic, null); + } + + AnsiConsole.Write(root); + } + + // ── Topic view: subtree of current topic / subpath ────────────────────────── + + private static void RenderTopic(ReplContext ctx) + { + var label = ctx.CurrentSubPath is null + ? $"[bold white]~/{ctx.CurrentTopic}/[/]" + : $"[bold white]~/{ctx.CurrentTopic}/{ctx.CurrentSubPath}/[/]"; + + var root = new Tree(label) + .Style(new Style(new Color(80, 80, 100))); + + var notes = ctx.NotesInView().ToList(); + AddNotesRecursive(root, notes, ctx.CurrentTopic!, ctx.CurrentSubPath); + + AnsiConsole.Write(root); + } + + // ── Recursive builder ──────────────────────────────────────────────────────── + + private static void AddNotesRecursive( + IHasTreeNodes parent, + List notes, + string topic, + string? subPath) + { + var depth = subPath is null ? 0 : subPath.Split('/').Length; + var topicPrefix = topic + "/"; + + // Slug relative to the topic root + string Relative(string slug) => + slug.StartsWith(topicPrefix, StringComparison.OrdinalIgnoreCase) + ? slug[topicPrefix.Length..] + : slug; + + // Notes directly at this level + var directNotes = notes + .Where(n => + { + var rel = Relative(n.Slug); + var segs = rel.Split('/'); + // must be exactly at current depth, and sub-path must match + if (segs.Length != depth + 1) return false; + if (subPath is null) return true; + return rel.StartsWith(subPath + "/") || rel == subPath; + }) + .OrderBy(n => Relative(n.Slug).Split('/')[depth]) + .ToList(); + + // Sub-folders at this level + var subFolders = notes + .Where(n => + { + var rel = Relative(n.Slug); + var segs = rel.Split('/'); + if (segs.Length <= depth + 1) return false; + if (subPath is null) return true; + return rel.StartsWith(subPath + "/"); + }) + .Select(n => Relative(n.Slug).Split('/')[depth]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToList(); + + // Render folders first + foreach (var folder in subFolders) + { + var folderSub = subPath is null ? folder : $"{subPath}/{folder}"; + var folderNotes = notes + .Where(n => + { + var rel = Relative(n.Slug); + return rel.StartsWith(folderSub + "/") || rel == folderSub; + }) + .ToList(); + + var count = folderNotes.Count; + var countStr = $"[dim grey]{count} {(count == 1 ? "note" : "notes")}[/]"; + var folderNode = parent.AddNode($"[grey46]β–Έ[/] [white]{Markup.Escape(folder)}/[/] {countStr}"); + + AddNotesRecursive(folderNode, folderNotes, topic, folderSub); + } + + // Then direct notes + foreach (var note in directNotes) + { + var (color, dot) = Style(note.Status); + var name = Relative(note.Slug).Split('/')[depth]; + parent.AddNode($"[{color}]{dot}[/] [white]{Markup.Escape(name)}[/] [dim]{Markup.Escape(note.Title)}[/]"); + } + } + + private static (string color, string dot) Style(string status) => status switch + { + "seed" => ("yellow", "β—¦"), + "budding" => ("magenta", "β—ˆ"), + "evergreen" => ("cyan", "β—‰"), + _ => ("grey", "β—‹"), + }; +}