- 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
170 lines
6.7 KiB
C#
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)] + "…";
|
|
}
|