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:
kryptomrx 2026-05-16 23:20:49 +02:00
parent 47cc904609
commit cf54165735
8 changed files with 322 additions and 91 deletions

View file

@ -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);
});
} }
} }

View file

@ -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;
} }
} }

View file

@ -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");

View file

@ -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);
} }

View file

@ -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)] + "…";
} }

View file

@ -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");

View file

@ -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; }

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