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
This commit is contained in:
parent
47cc904609
commit
cf54165735
8 changed files with 322 additions and 91 deletions
|
|
@ -9,17 +9,17 @@ public class CatCommand : ICommand
|
||||||
public string Description => "Print the raw markdown of a note";
|
public string Description => "Print the raw markdown of a note";
|
||||||
public string Usage => "cat <slug>";
|
public string Usage => "cat <slug>";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var slug = args.FirstOrDefault();
|
var slug = args.FirstOrDefault();
|
||||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
||||||
|
|
||||||
slug = ctx.ResolveSlug(slug);
|
slug = ctx.ResolveSlug(slug);
|
||||||
|
|
||||||
|
Core.Models.NoteContent note = default!;
|
||||||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||||
{
|
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||||
var note = await ctx.Api.GetNoteAsync(slug);
|
|
||||||
AnsiConsole.WriteLine(note.Markdown);
|
AnsiConsole.WriteLine(note.Markdown);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,40 +9,26 @@ public class CdCommand : ICommand
|
||||||
public string Description => "Navigate into a topic or folder";
|
public string Description => "Navigate into a topic or folder";
|
||||||
public string Usage => "cd <folder> | cd .. | cd ~";
|
public string Usage => "cd <folder> | 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 "~")
|
if (target is null or "~")
|
||||||
{
|
{
|
||||||
ctx.CurrentPath = "~";
|
ctx.CurrentTopic = null;
|
||||||
return;
|
ctx.CurrentSubPath = null;
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target == "..")
|
if (target == "..")
|
||||||
{
|
{
|
||||||
var parts = ctx.CurrentPath.TrimEnd('/').Split('/');
|
ctx.NavigateUp();
|
||||||
ctx.CurrentPath = parts.Length > 1
|
return Task.CompletedTask;
|
||||||
? string.Join("/", parts[..^1])
|
|
||||||
: "~";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidate = ctx.CurrentPath == "~"
|
if (!ctx.NavigateTo(target))
|
||||||
? $"~/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)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine($"[red]cd: {target}: no such folder[/]");
|
AnsiConsole.MarkupLine($"[red]cd: {target}: no such folder[/]");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.CurrentPath = candidate;
|
return Task.CompletedTask;
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ public class HelpCommand : ICommand
|
||||||
|
|
||||||
public HelpCommand(IReadOnlyList<ICommand> commands) => _commands = commands;
|
public HelpCommand(IReadOnlyList<ICommand> 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();
|
var table = new Table().BorderStyle(Style.Plain).HideHeaders();
|
||||||
table.AddColumns("cmd", "desc");
|
table.AddColumns("cmd", "desc");
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@ public interface ICommand
|
||||||
string Description { get; }
|
string Description { get; }
|
||||||
string Usage { get; }
|
string Usage { get; }
|
||||||
|
|
||||||
Task ExecuteAsync(string[] args, ReplContext ctx);
|
Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NozCli.Core.Models;
|
||||||
using NozCli.Repl;
|
using NozCli.Repl;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
|
|
@ -7,73 +8,163 @@ public class LsCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "ls";
|
public string Name => "ls";
|
||||||
public string Description => "List notes and folders at current path";
|
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 flags = string.Concat(args.Where(a => a.StartsWith('-')).SelectMany(a => a.TrimStart('-')));
|
||||||
var folders = ctx.FoldersInView().OrderBy(f => f).ToList();
|
var detailed = flags.Contains('l');
|
||||||
var prefix = ctx.PathPrefix();
|
var depth = ctx.CurrentSubPath is null ? 0 : ctx.CurrentSubPath.Split('/').Length;
|
||||||
|
|
||||||
// Determine which segments are folders (have children) vs direct notes
|
var (subFolders, directNotes) = Partition(ctx, depth);
|
||||||
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();
|
|
||||||
|
|
||||||
if (!detailed)
|
if (!detailed)
|
||||||
{
|
{
|
||||||
foreach (var folder in subFolders)
|
RenderShort(ctx, subFolders, directNotes, depth);
|
||||||
AnsiConsole.MarkupLine($" [blue]{folder}/[/]");
|
|
||||||
foreach (var note in directNotes)
|
|
||||||
AnsiConsole.MarkupLine($" [white]{note.Slug.Split('/').Last()}[/]");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var table = new Table().BorderStyle(Style.Plain).HideHeaders();
|
RenderLong(ctx, subFolders, directNotes, depth);
|
||||||
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);
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Short view ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void RenderShort(ReplContext ctx, List<string> folders, List<NoteInfo> 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<string> folders, List<NoteInfo> 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<string> folders, List<NoteInfo> 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)] + "…";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ public class RmCommand : ICommand
|
||||||
public string Description => "Delete a note from the server";
|
public string Description => "Delete a note from the server";
|
||||||
public string Usage => "rm <slug>";
|
public string Usage => "rm <slug>";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var slug = args.FirstOrDefault();
|
var slug = args.FirstOrDefault();
|
||||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: rm <slug>[/]"); return; }
|
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: rm <slug>[/]"); return; }
|
||||||
|
|
@ -20,7 +20,7 @@ public class RmCommand : ICommand
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await AnsiConsole.Status().StartAsync("Deleting…", async _ =>
|
await AnsiConsole.Status().StartAsync("Deleting…", async _ =>
|
||||||
await ctx.Api.DeleteNoteAsync(slug));
|
await ctx.Api.DeleteNoteAsync(slug, ct));
|
||||||
|
|
||||||
ctx.NoteCache.RemoveAll(n => n.Slug == slug);
|
ctx.NoteCache.RemoveAll(n => n.Slug == slug);
|
||||||
AnsiConsole.MarkupLine($"[green]✓[/] {slug} deleted");
|
AnsiConsole.MarkupLine($"[green]✓[/] {slug} deleted");
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ public class StatusCommand : ICommand
|
||||||
public string Description => "Show diff between a local folder and the server";
|
public string Description => "Show diff between a local folder and the server";
|
||||||
public string Usage => "status <local-path>";
|
public string Usage => "status <local-path>";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var localPath = args.FirstOrDefault();
|
var localPath = args.FirstOrDefault();
|
||||||
if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: status <local-path>[/]"); return; }
|
if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: status <local-path>[/]"); return; }
|
||||||
|
|
|
||||||
154
NozCli/Commands/TreeCommand.cs
Normal file
154
NozCli/Commands/TreeCommand.cs
Normal file
|
|
@ -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<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", "○"),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue