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", "○"), }; }