noz-cli/NozCli/Commands/LsCommand.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

170 lines
6.7 KiB
C#

using NozCli.Core.Models;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class LsCommand : ICommand
{
public string Name => "ls";
public string Description => "List notes and folders at current path";
public string Usage => "ls [-la]";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var flags = string.Concat(args.Where(a => a.StartsWith('-')).SelectMany(a => a.TrimStart('-')));
var detailed = flags.Contains('l');
var depth = ctx.CurrentSubPath is null ? 0 : ctx.CurrentSubPath.Split('/').Length;
var (subFolders, directNotes) = Partition(ctx, depth);
if (!detailed)
{
RenderShort(ctx, subFolders, directNotes, depth);
return;
}
RenderLong(ctx, subFolders, directNotes, depth);
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)] + "…";
}