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 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();
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
||||
|
||||
slug = ctx.ResolveSlug(slug);
|
||||
|
||||
Core.Models.NoteContent note = default!;
|
||||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||
{
|
||||
var note = await ctx.Api.GetNoteAsync(slug);
|
||||
AnsiConsole.WriteLine(note.Markdown);
|
||||
});
|
||||
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||
|
||||
AnsiConsole.WriteLine(note.Markdown);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,40 +9,26 @@ public class CdCommand : ICommand
|
|||
public string Description => "Navigate into a topic or folder";
|
||||
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 "~")
|
||||
{
|
||||
ctx.CurrentPath = "~";
|
||||
return;
|
||||
ctx.CurrentTopic = null;
|
||||
ctx.CurrentSubPath = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (target == "..")
|
||||
{
|
||||
var parts = ctx.CurrentPath.TrimEnd('/').Split('/');
|
||||
ctx.CurrentPath = parts.Length > 1
|
||||
? string.Join("/", parts[..^1])
|
||||
: "~";
|
||||
return;
|
||||
ctx.NavigateUp();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var candidate = ctx.CurrentPath == "~"
|
||||
? $"~/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)
|
||||
{
|
||||
if (!ctx.NavigateTo(target))
|
||||
AnsiConsole.MarkupLine($"[red]cd: {target}: no such folder[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.CurrentPath = candidate;
|
||||
await Task.CompletedTask;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public class HelpCommand : ICommand
|
|||
|
||||
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();
|
||||
table.AddColumns("cmd", "desc");
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ public interface ICommand
|
|||
string Description { 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 Spectre.Console;
|
||||
|
||||
|
|
@ -7,73 +8,163 @@ public class LsCommand : ICommand
|
|||
{
|
||||
public string Name => "ls";
|
||||
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 folders = ctx.FoldersInView().OrderBy(f => f).ToList();
|
||||
var prefix = ctx.PathPrefix();
|
||||
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;
|
||||
|
||||
// Determine which segments are folders (have children) vs direct notes
|
||||
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();
|
||||
var (subFolders, directNotes) = Partition(ctx, depth);
|
||||
|
||||
if (!detailed)
|
||||
{
|
||||
foreach (var folder in subFolders)
|
||||
AnsiConsole.MarkupLine($" [blue]{folder}/[/]");
|
||||
foreach (var note in directNotes)
|
||||
AnsiConsole.MarkupLine($" [white]{note.Slug.Split('/').Last()}[/]");
|
||||
RenderShort(ctx, subFolders, directNotes, depth);
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table().BorderStyle(Style.Plain).HideHeaders();
|
||||
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);
|
||||
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)] + "…";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public class RmCommand : ICommand
|
|||
public string Description => "Delete a note from the server";
|
||||
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();
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: rm <slug>[/]"); return; }
|
||||
|
|
@ -20,7 +20,7 @@ public class RmCommand : ICommand
|
|||
return;
|
||||
|
||||
await AnsiConsole.Status().StartAsync("Deleting…", async _ =>
|
||||
await ctx.Api.DeleteNoteAsync(slug));
|
||||
await ctx.Api.DeleteNoteAsync(slug, ct));
|
||||
|
||||
ctx.NoteCache.RemoveAll(n => n.Slug == slug);
|
||||
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 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();
|
||||
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