- 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
154 lines
5.4 KiB
C#
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", "○"),
|
|
};
|
|
}
|