noz-cli/NozCli/Commands/TreeCommand.cs
kryptomrx cf54165735 feat(commands): ls with detailed view, tree hierarchy, CancellationToken support
- ls: compact view with status dots and folder markers
- ls -la: table view with status, lang, date, title; folders show mini status bar
- tree: recursive hierarchy using Spectre.Console Tree widget, context-aware
- All commands: CancellationToken threaded through ExecuteAsync
- Partition() helper respects topic-relative slug depth
2026-05-16 23:20:49 +02:00

154 lines
5.4 KiB
C#

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