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 folders, List 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 folders, List 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 folders, List 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)] + "…"; }