304 lines
11 KiB
C#
304 lines
11 KiB
C#
using Sdt.Config;
|
|
using Sdt.Runner;
|
|
using Spectre.Console;
|
|
|
|
namespace Sdt.Tui;
|
|
|
|
/// <summary>Thin wrapper used in Spectre.Console selection prompts.</summary>
|
|
internal sealed record MenuItem(string Display, string Value);
|
|
|
|
public enum AppExitReason { Quit, SwitchProject }
|
|
public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null);
|
|
|
|
public sealed class App(
|
|
DevToolConfig config,
|
|
string projectRoot,
|
|
WorkspaceConfig? workspace = null,
|
|
string? workspaceRoot = null)
|
|
{
|
|
private DevToolConfig _config = config;
|
|
private string _projectRoot = projectRoot;
|
|
private readonly WorkspaceConfig? _workspace = workspace;
|
|
private readonly string? _workspaceRoot = workspaceRoot;
|
|
|
|
private IReadOnlyDictionary<string, BuildTarget> TargetMap =>
|
|
_config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
public async Task<AppResult> RunAsync()
|
|
{
|
|
while (true)
|
|
{
|
|
AnsiConsole.Clear();
|
|
RenderBanner();
|
|
|
|
var choice = ShowMainMenu();
|
|
|
|
switch (choice)
|
|
{
|
|
case "__env__":
|
|
EditEnvironment();
|
|
break;
|
|
|
|
case "__toolchains__":
|
|
await new ToolchainScreen(_config, _projectRoot).RunAsync();
|
|
break;
|
|
|
|
case "__workspace__":
|
|
if (_workspace is not null && _workspaceRoot is not null)
|
|
{
|
|
var switcher = new WorkspaceScreen(_workspace, _workspaceRoot, _projectRoot);
|
|
var newRoot = switcher.SelectProject();
|
|
if (newRoot is not null)
|
|
return new AppResult(AppExitReason.SwitchProject, newRoot);
|
|
}
|
|
break;
|
|
|
|
case "__quit__":
|
|
AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
|
|
return new AppResult(AppExitReason.Quit);
|
|
|
|
default:
|
|
var targetMap = TargetMap;
|
|
if (targetMap.TryGetValue(choice, out var target))
|
|
await RunTargetAsync(target, targetMap);
|
|
break;
|
|
}
|
|
|
|
if (choice != "__quit__")
|
|
{
|
|
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu..."));
|
|
Console.ReadKey(intercept: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Banner ────────────────────────────────────────────────────────────────
|
|
|
|
private void RenderBanner()
|
|
{
|
|
// Phosphor green figlet
|
|
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
|
|
|
|
// Project + workspace info line
|
|
var wsInfo = _workspace is not null
|
|
? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]"
|
|
: string.Empty;
|
|
|
|
AnsiConsole.Write(
|
|
new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}")
|
|
.RuleStyle(Theme.DimStyle));
|
|
|
|
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
|
|
}
|
|
|
|
// ── Main menu ─────────────────────────────────────────────────────────────
|
|
|
|
private string ShowMainMenu()
|
|
{
|
|
var prompt = new SelectionPrompt<MenuItem>()
|
|
.Title($"[{Theme.Green}]What would you like to do?[/]")
|
|
.PageSize(28)
|
|
.MoreChoicesText(Theme.Faint("(scroll to see more)"))
|
|
.UseConverter(m => m.Display);
|
|
|
|
// Targets, grouped
|
|
var groups = _config.Targets
|
|
.Where(t => !string.IsNullOrWhiteSpace(t.Label))
|
|
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group);
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
var header = new MenuItem(
|
|
$"[bold {Theme.Amber}]{Markup.Escape(group.Key.ToUpperInvariant())}[/]",
|
|
"__group__");
|
|
|
|
var items = group.Select(t => new MenuItem(
|
|
$"[{Theme.Green}]{Markup.Escape(t.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]",
|
|
t.Id)).ToList();
|
|
|
|
prompt.AddChoiceGroup(header, items);
|
|
}
|
|
|
|
// System actions
|
|
var systemItems = new List<MenuItem>
|
|
{
|
|
new($"[{Theme.Green}]⚙ Edit environment variables[/]", "__env__"),
|
|
};
|
|
|
|
if (_config.Toolchains is not null)
|
|
systemItems.Insert(0, new MenuItem(
|
|
$"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]",
|
|
"__toolchains__"));
|
|
|
|
if (_workspace is not null && (_workspace.Projects.Count > 1))
|
|
systemItems.Insert(0, new MenuItem(
|
|
$"[{Theme.Green}]⇄ Switch project[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
|
|
"__workspace__"));
|
|
|
|
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));
|
|
|
|
prompt.AddChoiceGroup(
|
|
new MenuItem($"[bold {Theme.Amber}]SYSTEM[/]", "__group__"),
|
|
systemItems);
|
|
|
|
return AnsiConsole.Prompt(prompt).Value;
|
|
}
|
|
|
|
// ── Target execution ──────────────────────────────────────────────────────
|
|
|
|
private async Task RunTargetAsync(BuildTarget target, IReadOnlyDictionary<string, BuildTarget> targetMap)
|
|
{
|
|
AnsiConsole.Clear();
|
|
AnsiConsole.Write(Theme.SectionRule(target.Label));
|
|
|
|
var plan = TargetRunner.ResolvePlan(target, targetMap);
|
|
|
|
if (plan.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine(Theme.Warn("This target has no executable steps."));
|
|
return;
|
|
}
|
|
|
|
if (plan.Count > 1)
|
|
{
|
|
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} steps:"));
|
|
foreach (var step in plan)
|
|
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(step.Label)}[/]");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
var allOk = true;
|
|
var totalSw = System.Diagnostics.Stopwatch.StartNew();
|
|
|
|
foreach (var step in plan)
|
|
{
|
|
AnsiConsole.Write(Theme.DimRule());
|
|
|
|
var workingDir = Path.GetFullPath(Path.Combine(_projectRoot, step.WorkingDir));
|
|
var cmdDisplay = $"{step.Command} {string.Join(" ", step.Args)}";
|
|
|
|
AnsiConsole.MarkupLine($"[{Theme.GreenDim}]$ {Markup.Escape(cmdDisplay)}[/]");
|
|
AnsiConsole.MarkupLine($"[{Theme.GreenDim}] {Markup.Escape(workingDir)}[/]\n");
|
|
|
|
RunResult result;
|
|
try
|
|
{
|
|
result = await ProcessRunner.RunAsync(
|
|
step.Command!,
|
|
step.Args,
|
|
workingDir,
|
|
(line, isErr) =>
|
|
{
|
|
var escaped = Markup.Escape(line);
|
|
AnsiConsole.MarkupLine(isErr
|
|
? $"[{Theme.Amber}]{escaped}[/]"
|
|
: $"[{Theme.Green}]{escaped}[/]");
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine("\n" + Theme.Fail($"Failed to launch: {ex.Message}"));
|
|
allOk = false;
|
|
break;
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
|
|
if (result.Success)
|
|
AnsiConsole.MarkupLine(Theme.Ok($"{step.Label} ({result.Elapsed.TotalSeconds:F1}s)"));
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine(Theme.Fail($"{step.Label} — exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
|
|
allOk = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
totalSw.Stop();
|
|
AnsiConsole.Write(Theme.SectionRule());
|
|
AnsiConsole.MarkupLine(allOk
|
|
? "\n" + Theme.Ok($"Done! Total: {totalSw.Elapsed.TotalSeconds:F1}s")
|
|
: "\n" + Theme.Fail("Build failed. Check output above."));
|
|
}
|
|
|
|
// ── Environment editor ────────────────────────────────────────────────────
|
|
|
|
private void EditEnvironment()
|
|
{
|
|
AnsiConsole.Clear();
|
|
|
|
if (_config.Env.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine(Theme.Warn("No environment variables defined in devtool.json."));
|
|
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
|
Console.ReadKey(intercept: true);
|
|
return;
|
|
}
|
|
|
|
while (true)
|
|
{
|
|
AnsiConsole.Write(Theme.SectionRule("ENVIRONMENT"));
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.BorderStyle(Theme.DimStyle)
|
|
.AddColumn(new TableColumn($"[{Theme.Amber}]Variable[/]"))
|
|
.AddColumn(new TableColumn($"[{Theme.Amber}]Current Value[/]"))
|
|
.AddColumn(new TableColumn($"[{Theme.Amber}]Description[/]"));
|
|
|
|
foreach (var def in _config.Env)
|
|
{
|
|
var val = Environment.GetEnvironmentVariable(def.Key) ?? def.DefaultValue;
|
|
table.AddRow(
|
|
Theme.Warn(def.Key),
|
|
Theme.Bold(val.Length > 0 ? val : "(not set)"),
|
|
Theme.Faint(def.Description));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine(Theme.Faint("Changes apply to this SDT session only.\n"));
|
|
|
|
var choices = _config.Env
|
|
.Select(e =>
|
|
{
|
|
var curr = Environment.GetEnvironmentVariable(e.Key) ?? e.DefaultValue;
|
|
return new MenuItem(
|
|
$"[{Theme.Amber}]{Markup.Escape(e.Key)}[/] [{Theme.GreenDim}]= {Markup.Escape(curr)}[/]",
|
|
e.Key);
|
|
})
|
|
.Append(new MenuItem(Theme.Faint("← Back"), "__back__"))
|
|
.ToList();
|
|
|
|
var selected = AnsiConsole.Prompt(
|
|
new SelectionPrompt<MenuItem>()
|
|
.Title($"[{Theme.Green}]Select a variable to edit:[/]")
|
|
.UseConverter(m => m.Display)
|
|
.AddChoices(choices));
|
|
|
|
if (selected.Value == "__back__") break;
|
|
|
|
var envDef = _config.Env.First(e => e.Key == selected.Value);
|
|
var current = Environment.GetEnvironmentVariable(envDef.Key) ?? envDef.DefaultValue;
|
|
|
|
string newVal;
|
|
if (envDef.Options.Count > 0)
|
|
{
|
|
newVal = AnsiConsole.Prompt(
|
|
new SelectionPrompt<string>()
|
|
.Title($"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/] [{Theme.GreenDim}]current: {Markup.Escape(current)}[/]")
|
|
.AddChoices(envDef.Options));
|
|
}
|
|
else
|
|
{
|
|
newVal = AnsiConsole.Ask(
|
|
$"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/]", current);
|
|
}
|
|
|
|
Environment.SetEnvironmentVariable(envDef.Key, newVal);
|
|
AnsiConsole.MarkupLine("\n" + Theme.Ok($"{envDef.Key} = {newVal}") + "\n");
|
|
Thread.Sleep(500);
|
|
AnsiConsole.Clear();
|
|
}
|
|
}
|
|
}
|