using Sdt.Config; using Sdt.Runner; using Spectre.Console; namespace Sdt.Tui; /// Thin wrapper used in Spectre.Console selection prompts. 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 TargetMap => _config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase); public async Task 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() .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 { 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 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() .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() .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(); } } }