diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..20dcdf2 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,28 @@ +name: PR Validation + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Restore + run: dotnet restore --nologo + + - name: Build + run: dotnet build --nologo --no-restore -c Release + + - name: Test + run: dotnet test --nologo --no-build -c Release diff --git a/AGENTS.md b/AGENTS.md index 7b0d91c..232ce46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,15 +61,41 @@ pass before presenting changes for review. ## UI Conventions -- **Menus** use `BrowserUI.SelectWithEscape` with hotkey support (the `extraKeys` parameter). Menu items embed the hotkey letter in blue within the label text: `[blue]E[/]dit filter`. This gives both arrow-key scrolling and single-keypress shortcuts. -- Inline hotkey bars (e.g. `[blue]T[/]ests [blue]J[/]obs [blue]Esc[/] Back`) follow the same format. -- **All menu locations** (update all when changing the format convention): - - `DashboardCommand.cs` — main menu, status view hotkey bar - - `BuildBrowser.cs` — build list hotkey bar, empty-list hotkey bar, filter menu, filter help, build detail hotkey bar, test failure hotkey bar, timeline issues hotkey bar - - `TestBrowser.cs` — test list hotkey bar, empty-list hotkey bar, filter menu, test detail hotkey bar - - `AnalysisBrowser.cs` — analysis detail menu, full log menu - - `AgentBrowser.cs` — agent list hotkey bar, agent detail menu - - `HealthCommand.cs` — state page menu, run detail menu - - `ConfigEditor.cs` — config menu -- **Escape** always means "go back" or "cancel" in any interactive context. +### Panel Layout + +All screens use `PanelLayout` for a consistent "command and control" look: +- **Header**: `TIGER ▸ Section ▸ Subsection` breadcrumb trail +- **Content area**: List selection or detail view +- **Command bar**: Bottom bar with hotkey commands, focusable via Tab + +### Command Bar (`CommandBarItem`) + +The command bar is the standard way to expose actions on any screen. It uses `List` where each item has a label, hotkey, and return value. + +- **Hotkey display**: Bracket style — `[B]uilds [T]ests [H]ealth` — with the bracketed letter rendered in blue. +- **Focus model**: Tab toggles focus between the list and the command bar. When the bar is focused, ←→ moves the highlight and Enter executes. Hotkey letters work regardless of focus. +- **Focused item**: Shown as `[bold white on blue] Label [/]` (inverted highlight). +- **Main menu**: Uses `PanelLayout.ShowMainMenu(commands)` — displays Figlet ASCII art + TIGER branding, navigation only via command bar. +- **List screens**: Use `PanelLayout.SelectInPanel(..., commands)` — list + Tab-focusable command bar. +- **Detail screens**: Use `PanelLayout.RenderDetailPanel(...)` with a static hotkey string footer (detail views handle their own key loops). + +### Hotkey Conventions + +- Hotkeys use the `[X]` bracket format in labels (e.g., `[E]dit filter`, `[R]efresh`) +- The bracket letter is highlighted in blue via Spectre markup: `[blue][[X]][/]` +- Escape always means "go back" or "cancel" +- Tab always switches focus to the command bar (on list screens) + +### All menu locations (update all when changing the format convention): + - `DashboardCommand.cs` — main menu (ShowMainMenu) + - `BuildBrowser.cs` — build list command bar, empty-list detail, filter menu, filter help, build detail, test failures, timeline issues + - `TestBrowser.cs` — test list command bar, empty-list detail, filter menu, test detail + - `AnalysisBrowser.cs` — analysis list command bar, analysis detail menu, full log menu + - `AgentBrowser.cs` — agent list command bar, agent detail menu + - `HealthCommand.cs` — health list command bar, runs list command bar, state page menu, run detail + - `ConfigEditor.cs` — config menu (still uses `BrowserUI.SelectWithEscape`) + +### Code Style + - **if/try/catch** bodies and braces must be on separate lines — never on the same line as the keyword. +- **Escape** always means "go back" or "cancel" in any interactive context. diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs new file mode 100644 index 0000000..1319934 --- /dev/null +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -0,0 +1,714 @@ +using Spectre.Console; +using Spectre.Console.Rendering; +using Spectre.Console.Testing; +using Tiger.Commands; +using Xunit; + +namespace Tiger.Tests; + +public class PanelRendererTests +{ + private static PanelRenderer CreateRenderer(int width = 80, int height = 24) + { + var console = new TestConsole().Width(width).Height(height); + return new PanelRenderer(console); + } + + // ── Content Capture ───────────────────────────────────────────── + + [Fact] + public void CaptureContent_CapturesAllLines() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("Line 1"); + renderer.RenderPanelLine("Line 2"); + renderer.RenderEmptyLine(); + renderer.RenderPanelLine("Line 3"); + }); + + Assert.Equal(4, lines.Count); + Assert.Equal("Line 1", lines[0]); + Assert.Equal("Line 2", lines[1]); + Assert.Equal("", lines[2]); + Assert.Equal("Line 3", lines[3]); + } + + [Fact] + public void CaptureContent_SectionTitle_IncludesMarkup() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderSectionTitle("My Section"); + }); + + Assert.Single(lines); + Assert.Equal("[bold underline]My Section[/]", lines[0]); + } + + [Fact] + public void CaptureContent_Field_IncludesLabelAndValue() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderField("Status", "running"); + }); + + Assert.Single(lines); + Assert.Equal("[bold]Status:[/] running", lines[0]); + } + + [Fact] + public void CaptureContent_ReturnsLines() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("stored"); + }); + + Assert.Single(lines); + Assert.Equal("stored", lines[0]); + } + + // ── Layout Calculations ───────────────────────────────────────── + + [Theory] + [InlineData(24, false, 18)] // 24 - 3 header - 3 footer = 18 + [InlineData(24, true, 17)] // 24 - 4 header (with context) - 3 footer = 17 + [InlineData(10, false, 5)] // minimum is 5 + [InlineData(30, false, 24)] // 30 - 3 - 3 = 24 + public void GetDetailAvailableHeight_CalculatesCorrectly(int terminalHeight, bool hasContext, int expected) + { + var renderer = CreateRenderer(width: 80, height: terminalHeight); + Assert.Equal(expected, renderer.GetDetailAvailableHeight(hasContext)); + } + + [Fact] + public void ContentWidth_Is_Width_Minus_4() + { + var renderer = CreateRenderer(width: 100, height: 24); + Assert.Equal(96, renderer.ContentWidth); + } + + [Fact] + public void ContentWidth_HasMinimum40() + { + var renderer = CreateRenderer(width: 30, height: 24); + Assert.Equal(40, renderer.ContentWidth); + } + + // ── Truncation ────────────────────────────────────────────────── + + [Fact] + public void TruncateToFit_ShortContent_Unchanged() + { + var renderer = CreateRenderer(width: 80); + var result = renderer.TruncateToFit("short text"); + Assert.Equal("short text", result); + } + + [Fact] + public void TruncateToFit_LongContent_Truncated() + { + var renderer = CreateRenderer(width: 20); // ContentWidth = 40 (minimum) + var longText = new string('x', 50); + var result = renderer.TruncateToFit(longText); + + var plainResult = Markup.Remove(result); + Assert.True(plainResult.Length <= 40); + Assert.EndsWith("...", plainResult); + } + + [Fact] + public void TruncateToFit_WithMarkup_UsesPlainTextLength() + { + var renderer = CreateRenderer(width: 50); // ContentWidth = 46 + // This markup has short plain text but long markup + var content = "[bold]short[/]"; + var result = renderer.TruncateToFit(content); + // Plain text "short" is 5 chars, fits in 46 + Assert.Equal(content, result); + } + + [Fact] + public void TruncateToFit_LongMarkupContent_TruncatesBasedOnPlainText() + { + var renderer = CreateRenderer(width: 24); // ContentWidth = 40 (minimum) + // Plain text will be 50 chars, exceeds 40 + var longText = $"[red]{new string('y', 50)}[/]"; + var result = renderer.TruncateToFit(longText); + + var plainResult = Markup.Remove(result); + Assert.True(plainResult.Length <= 40); + Assert.EndsWith("...", plainResult); + } + + // ── Hotkey Formatting ─────────────────────────────────────────── + + [Fact] + public void FormatHotkeyLabel_HighlightsFirstMatchingChar() + { + var item = new CommandBarItem("Builds", ConsoleKey.B, 1); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[B]][/]uilds", result); + } + + [Fact] + public void FormatHotkeyLabel_CaseInsensitiveMatch() + { + var item = new CommandBarItem("refresh", ConsoleKey.R, 2); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[r]][/]efresh", result); + } + + [Fact] + public void FormatHotkeyLabel_MiddleOfWord() + { + var item = new CommandBarItem("Agent task", ConsoleKey.A, 3); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[A]][/]gent task", result); + } + + [Fact] + public void FormatHotkeyLabel_NoMatch_ReturnsLabelUnchanged() + { + var item = new CommandBarItem("Builds", ConsoleKey.Z, 1); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("Builds", result); + } + + // ── BuildCommandBarString ─────────────────────────────────────── + + [Fact] + public void BuildCommandBarString_FormatsMultipleCommands() + { + var commands = new List + { + new("Tests", ConsoleKey.T, 1), + new("Jobs", ConsoleKey.J, 2), + }; + var result = PanelRenderer.BuildCommandBarString(commands); + Assert.Contains("[blue][[T]][/]ests", result); + Assert.Contains("[blue][[J]][/]obs", result); + Assert.Contains("[blue]Esc[/] Back", result); + } + + [Fact] + public void BuildCommandBarString_EmptyList_JustEscBack() + { + var commands = new List(); + var result = PanelRenderer.BuildCommandBarString(commands); + Assert.Equal(" [blue]Esc[/] Back", result); + } + + // ── Frame Rendering (integration with TestConsole) ────────────── + + [Fact] + public void RenderDetailFrame_IncludesBreadcrumbs() + { + var console = new TestConsole().Width(80).Height(24); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("Content here"); + }); + + renderer.RenderDetailFrame(["Builds", "#123"], null, lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.Contains("TIGER", output); + Assert.Contains("Builds", output); + Assert.Contains("#123", output); + Assert.Contains("Content here", output); + } + + [Fact] + public void RenderDetailFrame_ShowsContext() + { + var console = new TestConsole().Width(80).Height(24); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("data"); + }); + + renderer.RenderDetailFrame(["Tests"], "3 failures", lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.Contains("3 failures", output); + } + + [Fact] + public void RenderDetailFrame_PaginatesContent() + { + var console = new TestConsole().Width(80).Height(12); + // Height 12: available = 12 - 3 header - 3 footer = 6 lines + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + for (var i = 0; i < 20; i++) + { + renderer.RenderPanelLine($"Line {i}"); + } + }); + + renderer.RenderDetailFrame(["Test"], null, lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + // First 6 lines should be visible + Assert.Contains("Line 0", output); + Assert.Contains("Line 5", output); + // Line 6+ should NOT be visible (paginated away) + Assert.DoesNotContain("Line 6", output); + // Scroll indicator should show + Assert.Contains("1-6/20", output); + } + + [Fact] + public void RenderDetailFrame_ScrollOffset_ShowsLaterContent() + { + var console = new TestConsole().Width(80).Height(12); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + for (var i = 0; i < 20; i++) + { + renderer.RenderPanelLine($"Line {i}"); + } + }); + + renderer.RenderDetailFrame(["Test"], null, lines, 5, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.DoesNotContain("Line 4", output); + Assert.Contains("Line 5", output); + Assert.Contains("Line 10", output); + Assert.Contains("6-11/20", output); + } + + // ── No Unicode in rendered output ─────────────────────────────── + + [Fact] + public void FormatHotkeyLabel_ProducesOnlyAsciiAndSpectreMarkup() + { + var items = new[] + { + new CommandBarItem("Builds", ConsoleKey.B, 1), + new CommandBarItem("Tests", ConsoleKey.T, 2), + new CommandBarItem("Helix", ConsoleKey.H, 3), + new CommandBarItem("Agent task", ConsoleKey.A, 4), + }; + + foreach (var item in items) + { + var result = PanelRenderer.FormatHotkeyLabel(item); + var plain = Markup.Remove(result); + AssertAsciiOnly(plain, $"FormatHotkeyLabel({item.Label})"); + } + } + + [Fact] + public void BuildCommandBarString_ProducesAsciiPlainText() + { + var commands = new List + { + new("Builds", ConsoleKey.B, 1), + new("Tests", ConsoleKey.T, 2), + }; + var result = PanelRenderer.BuildCommandBarString(commands); + var plain = Markup.Remove(result); + AssertAsciiOnly(plain, "BuildCommandBarString"); + } + + private static void AssertAsciiOnly(string text, string context) + { + for (var i = 0; i < text.Length; i++) + { + Assert.True(text[i] <= 127, + $"Non-ASCII char U+{(int)text[i]:X4} ('{text[i]}') at position {i} in {context}: \"{text}\""); + } + } + + // ── Helix Work Item Display Format ────────────────────────────── + + [Fact] + public void HelixWorkItem_SectionTitle_IncludesFailed() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + // Mirrors BuildBrowser.RenderBuildDetail helix section + var count = 2; + renderer.RenderSectionTitle($"Failed Helix Work Items ({count})"); + }); + + Assert.Single(lines); + Assert.Contains("Failed Helix Work Items", lines[0]); + } + + [Fact] + public void HelixWorkItem_Format_IncludesExitCode() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + // Mirrors the per-item rendering in BuildBrowser + var wi = "workitem1"; + var job = "job-abc123"; + int? exitCode = 1; + var isDeadletter = false; + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + renderer.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + }); + + Assert.Single(lines); + var plain = Markup.Remove(lines[0]); + Assert.Contains("workitem1", plain); + Assert.Contains("job-abc123", plain); + Assert.Contains("exit 1", plain); + } + + [Fact] + public void HelixWorkItem_Deadletter_Format_IncludesExitCodeAndDeadletter() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + var wi = "workitem2"; + var job = "job-def456"; + int? exitCode = -1; + var isDeadletter = true; + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + renderer.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + }); + + Assert.Single(lines); + var plain = Markup.Remove(lines[0]); + Assert.Contains("workitem2", plain); + Assert.Contains("job-def456", plain); + Assert.Contains("exit -1", plain); + Assert.Contains("deadletter", plain); + } + + // ── Cursor Redraw (partial update) ────────────────────────────── + + [Fact] + public void SelectInPanel_CursorMove_PreservesSeparator() + { + // Simulates: list with context, press Down, then Escape + // Verifies the partial redraw targets correct rows (not the separator) + var console = new TestConsole().Width(80).Height(24); + // Push keys: Down to move cursor, then Escape to exit + console.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + var result = renderer.SelectInPanel( + ["Builds"], + "[dim]3 items[/]", + items, + commands); + + Assert.Equal(-1, result); // escaped + + var output = console.Output; + + // Verify the partial redraw wrote "Item B" with ">" prefix (cursor moved to it) + var lastItemB = output.LastIndexOf("Item B"); + Assert.True(lastItemB > 0); + // The last "Item B" should have > before it (the cursor indicator) + var segmentAroundB = output[(lastItemB - 10)..lastItemB]; + Assert.Contains(">", segmentAroundB); + } + + [Fact] + public void SelectInPanel_CursorMove_SetPosition_Uses1BasedRows() + { + // Verifies that SetPosition is called with 1-based row coordinates. + // With context, the layout is: + // Row 1 (1-based): top border + // Row 2: header + // Row 3: context + // Row 4: mid separator + // Row 5: first list item (initially selected) + // Row 6: second list item + // Row 7: third list item + // + // After pressing Down, partial redraw should call: + // SetPosition(0, 5) to deselect first item + // SetPosition(0, 6) to select second item + // NOT row 4 (the separator) or row 3! + + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Builds"], "[dim]3 items[/]", items, commands); + + // Verify SetPosition calls target the correct 1-based rows + Assert.True(spy.SetPositionCalls.Count >= 2, + $"Expected at least 2 SetPosition calls, got {spy.SetPositionCalls.Count}"); + + // First call: deselect first item at row 5 (1-based) + var (col1, row1) = spy.SetPositionCalls[0]; + Assert.Equal(5, row1); // row 5 = first list item (1-based) + + // Second call: select second item at row 6 (1-based) + var (col2, row2) = spy.SetPositionCalls[1]; + Assert.Equal(6, row2); // row 6 = second list item (1-based) + + // Crucially: no SetPosition should target row 4 (the separator) + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 4); + } + + [Fact] + public void SelectInPanel_CursorMove_NoContext_SetPosition_Uses1BasedRows() + { + // Without context, layout is: + // Row 1: top border + // Row 2: header + // Row 3: mid separator + // Row 4: first list item + // Row 5: second list item + // + // After Down, SetPosition should target rows 4 and 5. + + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Test"], null, items, commands); + + Assert.True(spy.SetPositionCalls.Count >= 2); + + // First call: deselect first item at row 4 (1-based, no context) + Assert.Equal(4, spy.SetPositionCalls[0].Line); + // Second call: select second item at row 5 + Assert.Equal(5, spy.SetPositionCalls[1].Line); + // No call should target row 3 (the separator) + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 3); + } + + [Fact] + public void SelectInPanel_BottomBorder_NoTrailingNewline() + { + // Verifies that the frame doesn't emit a trailing newline after the bottom border. + // A trailing newline would cause terminal scroll when the frame fills the screen, + // which breaks absolute cursor positioning for partial redraws. + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var items = new List { "Item A", "Item B" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Test"], null, items, commands); + + var output = console.Output; + + // The output should NOT end with a newline — the bottom border + // is rendered with Markup (not MarkupLine) to prevent scroll + Assert.False(output.EndsWith("\n"), "Frame should not end with trailing newline"); + } + + // ── PromptInPanel ──────────────────────────────────────────────── + + [Fact] + public void PromptInPanel_PromptTextNotOverwritten() + { + // Verifies the prompt text line is rendered intact — the "> " input cursor + // must NOT overwrite part of the prompt text (regression: was writing at row -4 + // which landed on the prompt line instead of the empty input line at row -3). + var console = new TestConsole().Width(80).Height(24); + // Press Escape immediately to exit the prompt + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); + + Assert.Null(result); + + var output = console.Output; + // The prompt text must appear fully intact (not overwritten by "> ") + Assert.Contains("Definition pattern (e.g. ci, roslyn-CI*)", output); + // Breadcrumbs must appear + Assert.Contains("Builds", output); + Assert.Contains("Filter", output); + } + + [Fact] + public void PromptInPanel_CursorPosition_TargetsInputLine() + { + // Verifies SetPosition targets the empty input line (row 5 with no currentValue) + // Layout: row1=border, row2=header, row3=separator, row4=prompt, row5=input line + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + // SetPosition should target row 5 (the empty input line), NOT row 4 (prompt text) + Assert.Contains(spy.SetPositionCalls, call => call.Line == 5); + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 4); + } + + [Fact] + public void PromptInPanel_EnterReturnsTypedText() + { + var console = new TestConsole().Width(80).Height(24); + // Type "roslyn" then press Enter + foreach (var c in "roslyn") + { + console.Input.PushKey(new ConsoleKeyInfo(c, ConsoleKey.A, false, false, false)); + } + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + Assert.Equal("roslyn", result); + } + + [Fact] + public void PromptInPanel_EmptyInput_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + // Just press Enter with no text + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + Assert.Null(result); + } + + // ── PromptKindFilter ───────────────────────────────────────────── + + [Fact] + public void PromptKindFilter_Escape_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Null(result); + } + + [Fact] + public void PromptKindFilter_SelectPr_ReturnsPr() + { + var console = new TestConsole().Width(80).Height(24); + // Items are: all, pr, ci — "pr" is at index 1, so press Down then Enter + console.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Equal("pr", result); + } + + [Fact] + public void PromptKindFilter_SelectAll_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + // "all" is at index 0 (default selection), just press Enter + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Null(result); + } + + [Fact] + public void PromptKindFilter_RendersInPanel_WithBreadcrumbs() + { + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + BrowserUI.PromptKindFilter(renderer); + + var output = console.Output; + // Breadcrumbs should appear in header + Assert.Contains("Builds", output); + Assert.Contains("Filter", output); + Assert.Contains("Kind", output); + // Items should appear in content + Assert.Contains("all", output); + Assert.Contains("pr", output); + Assert.Contains("ci", output); + } +} + +/// +/// A wrapper around TestConsole that records Cursor.SetPosition calls +/// for verifying cursor positioning in tests. +/// +file class SpyConsole : IAnsiConsole +{ + public TestConsole Inner { get; } + public List<(int Column, int Line)> SetPositionCalls { get; } = new(); + + private readonly SpyCursor _cursor; + + public SpyConsole(int width = 80, int height = 24) + { + Inner = new TestConsole().Width(width).Height(height); + _cursor = new SpyCursor(Inner.Cursor, this); + } + + public string Output => Inner.Output; + public IAnsiConsoleCursor Cursor => _cursor; + public IAnsiConsoleInput Input => Inner.Input; + public IExclusivityMode ExclusivityMode => Inner.ExclusivityMode; + public RenderPipeline Pipeline => Inner.Pipeline; + public Profile Profile => Inner.Profile; + + public void Clear(bool home) => Inner.Clear(home); + public void Write(IRenderable renderable) => Inner.Write(renderable); + public void WriteAnsi(Action action) => Inner.WriteAnsi(action); + + private class SpyCursor : IAnsiConsoleCursor + { + private readonly IAnsiConsoleCursor _inner; + private readonly SpyConsole _spy; + + public SpyCursor(IAnsiConsoleCursor inner, SpyConsole spy) + { + _inner = inner; + _spy = spy; + } + + public void SetPosition(int column, int line) + { + _spy.SetPositionCalls.Add((column, line)); + _inner.SetPosition(column, line); + } + + public void Move(CursorDirection direction, int steps) => _inner.Move(direction, steps); + public void Show(bool show) => _inner.Show(show); + } +} diff --git a/src/Tiger.Tests/Tiger.Tests.csproj b/src/Tiger.Tests/Tiger.Tests.csproj index b6ae087..80404d5 100644 --- a/src/Tiger.Tests/Tiger.Tests.csproj +++ b/src/Tiger.Tests/Tiger.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Tiger/Commands/AgentBrowser.cs b/src/Tiger/Commands/AgentBrowser.cs index 7bf5f3c..3582f50 100644 --- a/src/Tiger/Commands/AgentBrowser.cs +++ b/src/Tiger/Commands/AgentBrowser.cs @@ -10,6 +10,8 @@ namespace Tiger.Commands; /// public sealed class AgentBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; public AgentBrowser(TigerDatabase db) @@ -21,23 +23,28 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Agent Tasks[/]"); - AnsiConsole.WriteLine(); - var tasks = LoadTasks(); if (tasks is null) { - AnsiConsole.MarkupLine("[red]Failed to load agent tasks from gh CLI.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Agents"], + null, + () => _ui.RenderPanelLine("[red]Failed to load agent tasks from gh CLI.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } if (tasks.Count == 0) { - AnsiConsole.MarkupLine("[dim]No agent tasks found.[/]"); - AnsiConsole.MarkupLine(" [blue]R[/]efresh [blue]Esc[/] Back"); + _ui.RenderDetailPanel( + ["Agents"], + null, + () => _ui.RenderPanelLine("[dim]No agent tasks found.[/]"), + PanelRenderer.BuildCommandBarString(new List + { + new("Refresh", ConsoleKey.R, -2), + })); while (true) { @@ -70,19 +77,20 @@ public void Browse() var prInfo = task.PullRequestNumber is not null ? $" PR #{task.PullRequestNumber}" : ""; - var tigerMark = isTracked ? " [yellow]★[/]" : ""; + var tigerMark = isTracked ? " [yellow]*[/]" : ""; items.Add($"{stateIcon} {Markup.Escape(name)}{prInfo}{tigerMark} [dim]{Markup.Escape(repo)}[/]"); } - var extraKeys = new Dictionary + var commands = new List { - [ConsoleKey.R] = -2, + new("Refresh", ConsoleKey.R, -2), }; - AnsiConsole.MarkupLine(" [blue]R[/]efresh [yellow]★[/] = submitted from Tiger"); - AnsiConsole.WriteLine(); - - var selected = BrowserUI.SelectWithEscape("", items, useMarkup: true, extraKeys: extraKeys); + var selected = _ui.SelectInPanel( + ["Agents"], + $"[dim]{tasks.Count} task(s)[/] [yellow]*[/] = submitted from Tiger", + items, + commands); if (selected == -1) { @@ -101,47 +109,8 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) { while (true) { - AnsiConsole.Clear(); - var isTracked = task.Id is not null && trackedIds.Contains(task.Id); - var headerTable = new Table().Border(TableBorder.Rounded); - headerTable.AddColumn(new TableColumn("").NoWrap()); - headerTable.AddColumn(new TableColumn("")); - headerTable.HideHeaders(); - - headerTable.AddRow("[bold]Name[/]", Markup.Escape(task.Name ?? "unnamed")); - headerTable.AddRow("[bold]State[/]", FormatState(task.State)); - headerTable.AddRow("[bold]Repository[/]", Markup.Escape(task.Repository ?? "unknown")); - if (task.Id is not null) - { - headerTable.AddRow("[bold]Session[/]", Markup.Escape(task.Id)); - } - if (task.CreatedAt is not null) - { - headerTable.AddRow("[bold]Created[/]", BrowserUI.FormatTime(task.CreatedAt)); - } - if (task.UpdatedAt is not null) - { - headerTable.AddRow("[bold]Updated[/]", BrowserUI.FormatTime(task.UpdatedAt)); - } - if (task.PullRequestNumber is not null && task.PullRequestUrl is not null) - { - headerTable.AddRow("[bold]Pull Request[/]", - $"{BrowserUI.FormatLink(task.PullRequestUrl, $"PR #{task.PullRequestNumber}")} ({Markup.Escape(task.PullRequestState ?? "unknown")})"); - } - else if (task.PullRequestNumber is not null) - { - headerTable.AddRow("[bold]Pull Request[/]", $"#{task.PullRequestNumber}"); - } - if (isTracked) - { - headerTable.AddRow("[bold]Source[/]", "[yellow]Submitted from Tiger[/]"); - } - - AnsiConsole.Write(headerTable); - AnsiConsole.WriteLine(); - var menuItems = new List(); var actions = new List(); var extraKeys = new Dictionary(); @@ -161,13 +130,64 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) extraKeys[ConsoleKey.R] = menuItems.Count - 1; actions.Add("refresh"); - var choice = BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); - if (choice < 0) + // Use RenderDetailPanel for the header info, then SelectInPanel for menu + _ui.RenderDetailPanel( + ["Agents", Markup.Escape(task.Name ?? "unnamed")], + $"{FormatState(task.State)} {Markup.Escape(task.Repository ?? "unknown")}", + () => + { + _ui.RenderField("Name", Markup.Escape(task.Name ?? "unnamed")); + _ui.RenderField("State", FormatState(task.State)); + _ui.RenderField("Repository", Markup.Escape(task.Repository ?? "unknown")); + if (task.Id is not null) + { + _ui.RenderField("Session", Markup.Escape(task.Id)); + } + if (task.CreatedAt is not null) + { + _ui.RenderField("Created", BrowserUI.FormatTime(task.CreatedAt)); + } + if (task.UpdatedAt is not null) + { + _ui.RenderField("Updated", BrowserUI.FormatTime(task.UpdatedAt)); + } + if (task.PullRequestNumber is not null && task.PullRequestUrl is not null) + { + _ui.RenderField("Pull Request", + $"{BrowserUI.FormatLink(task.PullRequestUrl, $"PR #{task.PullRequestNumber}")} ({Markup.Escape(task.PullRequestState ?? "unknown")})"); + } + else if (task.PullRequestNumber is not null) + { + _ui.RenderField("Pull Request", $"#{task.PullRequestNumber}"); + } + if (isTracked) + { + _ui.RenderField("Source", "[yellow]Submitted from Tiger[/]"); + } + }, + PanelRenderer.BuildCommandBarString(new List + { + new("Open PR", ConsoleKey.O, -2), + new("View logs", ConsoleKey.V, -3), + new("Refresh", ConsoleKey.R, -4), + })); + + var key = Console.ReadKey(true); + if (key.Key == ConsoleKey.Escape) { return; } - switch (actions[choice]) + // Map key to action + string? action = key.Key switch + { + ConsoleKey.O when task.PullRequestUrl is not null => "open_pr", + ConsoleKey.V => "logs", + ConsoleKey.R => "refresh", + _ => null, + }; + + switch (action) { case "open_pr": var openProcess = new System.Diagnostics.Process(); @@ -288,11 +308,11 @@ private static void ShowLogs(AgentTaskInfo task) private static string FormatState(string? state) => state switch { - "completed" => "[green]✓ completed[/]", - "in_progress" => "[blue]● in progress[/]", - "cancelled" => "[dim]✕ cancelled[/]", - "waiting" => "[yellow]◌ waiting[/]", - "queued" => "[yellow]◌ queued[/]", + "completed" => "[green]+ completed[/]", + "in_progress" => "[blue]> in progress[/]", + "cancelled" => "[dim]X cancelled[/]", + "waiting" => "[yellow]- waiting[/]", + "queued" => "[yellow]- queued[/]", _ => Markup.Escape(state ?? "unknown"), }; @@ -331,3 +351,5 @@ internal sealed class AgentTaskInfo public string? PullRequestState { get; set; } } } + + diff --git a/src/Tiger/Commands/AnalysisBrowser.cs b/src/Tiger/Commands/AnalysisBrowser.cs index b883979..42f3335 100644 --- a/src/Tiger/Commands/AnalysisBrowser.cs +++ b/src/Tiger/Commands/AnalysisBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class AnalysisBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly BuildAnalysisService? _analysisService; private readonly AzdoClientFactory _clientFactory; @@ -25,15 +27,14 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold]Build Failure Analysis[/]"); - AnsiConsole.WriteLine(); - var analyses = _db.GetRecentAnalyses(50); if (analyses.Count == 0) { - AnsiConsole.MarkupLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"); - AnsiConsole.MarkupLine("[dim]Press Escape to go back.[/]"); + _ui.RenderDetailPanel( + ["Analysis"], + null, + () => _ui.RenderPanelLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } @@ -42,17 +43,16 @@ public void Browse() { var statusIcon = a.Status switch { - "complete" => "[green]✓[/]", - "running" => "[yellow]⟳[/]", - "pending" => "[dim]…[/]", - "skipped" => "[blue]⊘[/]", - "failed" => "[red]✗[/]", + "complete" => "[green]+[/]", + "running" => "[yellow]~[/]", + "pending" => "[dim]...[/]", + "skipped" => "[blue]-[/]", + "failed" => "[red]X[/]", _ => "[dim]?[/]", }; var category = a.Category is not null ? $"[dim]({Markup.Escape(a.Category)})[/]" : ""; var label = $"{statusIcon} {Markup.Escape(a.DefinitionName)} #{a.BuildId} {category}"; - // Add a brief one-line summary if (a.DiagnosisSummary is not null) { var firstLine = a.DiagnosisSummary.Split('\n')[0].Trim(); @@ -66,7 +66,13 @@ public void Browse() return label; }).ToList(); - var selected = BrowserUI.SelectWithEscape("", items, useMarkup: true); + var commands = new List(); + + var selected = _ui.SelectInPanel( + ["Analysis"], + $"[dim]{analyses.Count} analysis result(s)[/]", + items, + commands); if (selected < 0) { return; @@ -240,3 +246,5 @@ private static void ShowFullLog(BuildAnalysisInfo analysis) _ => Markup.Escape(status), }; } + + diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index 3233768..f79a167 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -66,7 +66,7 @@ public static int SelectWithEscape(string title, List items, int pageSiz Console.Write(new string(' ', Console.WindowWidth)); Console.SetCursorPosition(0, Console.CursorTop); - var builtIn = "[blue]↑↓[/] Navigate [blue]Enter[/] Select [blue]Esc[/] Back"; + var builtIn = "[blue]Up/Dn[/] Navigate [blue]Enter[/] Select [blue]Esc[/] Back"; var footer = hotkeys is not null ? $" {hotkeys} {builtIn}" : $" {builtIn}"; @@ -160,11 +160,15 @@ public static int SelectWithEscape(string title, List items, int pageSiz /// /// Selection menu for build kind filter (pr/ci). Returns null if cancelled or "all" selected. /// - public static string? PromptKindFilter() + public static string? PromptKindFilter(PanelRenderer ui) { - AnsiConsole.WriteLine(); - var choices = new[] { "all", "pr", "ci" }; - var selected = SelectWithEscape("Select build kind:", choices.ToList(), pageSize: 5); + var choices = new List { "all", "pr", "ci" }; + var commands = new List(); + var selected = ui.SelectInPanel( + ["Builds", "Filter", "Kind"], + "[dim]Select build kind to filter on[/]", + choices, + commands); if (selected < 0) return null; return choices[selected] == "all" ? null : choices[selected]; } @@ -191,21 +195,21 @@ public static string FormatTime(string? isoTime) public static string FormatResult(string? result) => result switch { - "succeeded" => "[green]✓ succeeded[/]", - "failed" => "[red]✗ failed[/]", - "partiallySucceeded" => "[yellow]⚠ partial[/]", + "succeeded" => "[green]+ succeeded[/]", + "failed" => "[red]X failed[/]", + "partiallySucceeded" => "[yellow]! partial[/]", "canceled" => "[dim]canceled[/]", - null => "[dim]—[/]", + null => "[dim]-[/]", _ => result, }; public static string FormatResultIcon(string? result) => result switch { - "succeeded" => "[green]✓[/]", - "failed" => "[red]✗[/]", - "partiallySucceeded" => "[yellow]⚠[/]", - "canceled" => "[dim]⊘[/]", - _ => "[dim]—[/]", + "succeeded" => "[green]+[/]", + "failed" => "[red]X[/]", + "partiallySucceeded" => "[yellow]![/]", + "canceled" => "[dim]-[/]", + _ => "[dim]-[/]", }; /// @@ -217,7 +221,7 @@ public static string FormatBuildChoice(int buildId, string definitionName, strin var icon = FormatResultIcon(result); var pr = prNumber is not null ? $" PR#{prNumber}" : ""; var time = FormatTime(finishTime); - var pendingIcon = pending ? " ⏳" : ""; + var pendingIcon = pending ? " [dim]...[/]" : ""; return $"{icon} {buildId} {Markup.Escape(definitionName)} {time}{pr}{pendingIcon}"; } @@ -229,7 +233,7 @@ public static void RenderTestDetail(TestDetailInfo info) // Deadletter banner if (info.IsHelixDeadletter) { - AnsiConsole.MarkupLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + AnsiConsole.MarkupLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); AnsiConsole.WriteLine(); } @@ -281,7 +285,7 @@ public static void RenderTestDetail(TestDetailInfo info) { if (info.IsHelixDeadletter) { - AnsiConsole.MarkupLine(" [bold red]⚠ DEAD LETTER[/]"); + AnsiConsole.MarkupLine(" [bold red]!! DEAD LETTER[/]"); } AnsiConsole.MarkupLine($" [bold]Job:[/] {Markup.Escape(info.HelixJobName)}"); if (info.HelixWorkItemName is not null) @@ -315,10 +319,103 @@ public static void RenderTestDetail(TestDetailInfo info) } /// - /// Converts a user pattern to a SQL LIKE pattern or exact match. - /// Default: contains match (ros → %ros%). Trailing ! means exact match. - /// * is a wildcard (dotnet/* → dotnet/%). + /// Renders test detail info using PanelRenderer (for use inside RenderDetailPanel content delegates). /// + public static void RenderTestDetailInPanel(PanelRenderer ui, TestDetailInfo info) + { + if (info.IsHelixDeadletter) + { + ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + ui.RenderEmptyLine(); + } + + ui.RenderField("Test Name", Markup.Escape(info.TestName)); + var buildUrl = $"https://dev.azure.com/{Uri.EscapeDataString(info.Org)}/{Uri.EscapeDataString(info.Project)}/_build/results?buildId={info.BuildId}"; + ui.RenderField("Last Failed Build", Markup.Escape(buildUrl)); + ui.RenderField("Run", Markup.Escape(info.RunName)); + ui.RenderField("Failed In", $"{info.BuildCount} build(s)"); + ui.RenderEmptyLine(); + + ui.RenderSectionTitle("Error"); + if (!string.IsNullOrWhiteSpace(info.ErrorMessage)) + { + var errorLines = info.ErrorMessage.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in errorLines.Take(5)) + { + ui.RenderPanelLine($" [red]{Markup.Escape(line)}[/]"); + } + if (errorLines.Length > 5) + { + ui.RenderPanelLine($" [dim]... ({errorLines.Length - 5} more lines)[/]"); + } + } + else + { + ui.RenderPanelLine(" [dim]No error message available[/]"); + } + ui.RenderEmptyLine(); + + ui.RenderSectionTitle("Stack Trace"); + if (!string.IsNullOrWhiteSpace(info.StackTrace)) + { + var stackLines = info.StackTrace.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in stackLines.Take(10)) + { + ui.RenderPanelLine($" [dim]{Markup.Escape(line)}[/]"); + } + if (stackLines.Length > 10) + { + ui.RenderPanelLine($" [dim]... ({stackLines.Length - 10} more lines)[/]"); + } + } + else + { + ui.RenderPanelLine(" [dim]No stack trace available[/]"); + } + ui.RenderEmptyLine(); + + ui.RenderSectionTitle("Helix"); + if (info.HelixJobName is not null) + { + if (info.IsHelixDeadletter) + { + ui.RenderPanelLine(" [bold red]!! DEAD LETTER[/]"); + } + ui.RenderField("Job", Markup.Escape(info.HelixJobName)); + if (info.HelixWorkItemName is not null) + { + ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + if (info.HelixExitCode is not null) + { + var exitColor = info.HelixExitCode == 0 ? "green" : "red"; + ui.RenderField("Exit Code", $"[{exitColor}]{info.HelixExitCode}[/]"); + } + var consoleUrl = HelixClient.GetConsoleUrl(info.HelixJobName, info.HelixWorkItemName); + ui.RenderField("Console", FormatLink(consoleUrl, "Console Log")); + + if (info.HelixFiles is { Count: > 0 }) + { + ui.RenderPanelLine($" [bold]Files ({info.HelixFiles.Count}):[/]"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + ui.RenderPanelLine($" {FormatLink(uri, name)}"); + } + else + { + ui.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + } + } + else + { + ui.RenderPanelLine(" [dim]No Helix information available[/]"); + } + } + public static (string Pattern, bool IsExact) ToSqlPattern(string input) { if (input.EndsWith('!')) @@ -356,7 +453,8 @@ public record TestDetailInfo( string? ErrorMessage, string? StackTrace, string? HelixJobName, string? HelixWorkItemName, List<(string Name, string? Uri)>? HelixFiles = null, - bool IsHelixDeadletter = false); + bool IsHelixDeadletter = false, + int? HelixExitCode = null); /// /// Loads test detail info from the database. @@ -419,12 +517,13 @@ FROM test_results tr // Load helix files and deadletter status if available List<(string Name, string? Uri)>? helixFiles = null; var isDeadletter = false; + int? helixExitCode = null; if (detail.HelixJob is not null && detail.HelixWorkItem is not null) { var helixInfo = db.WithCommand(cmd => { cmd.CommandText = """ - SELECT files, is_deadletter FROM helix_work_items + SELECT files, is_deadletter, exit_code FROM helix_work_items WHERE job_name = @job AND work_item_name = @wi """; cmd.Parameters.AddWithValue("@job", detail.HelixJob); @@ -434,12 +533,14 @@ FROM test_results tr { return ( FilesJson: reader.IsDBNull(0) ? null : reader.GetString(0), - IsDeadletter: !reader.IsDBNull(1) && reader.GetInt32(1) != 0); + IsDeadletter: !reader.IsDBNull(1) && reader.GetInt32(1) != 0, + ExitCode: reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2)); } - return (FilesJson: (string?)null, IsDeadletter: false); + return (FilesJson: (string?)null, IsDeadletter: false, ExitCode: (int?)null); }); isDeadletter = helixInfo.IsDeadletter; + helixExitCode = helixInfo.ExitCode; if (!string.IsNullOrWhiteSpace(helixInfo.FilesJson)) { @@ -459,7 +560,7 @@ FROM test_results tr } return new TestDetailInfo(testName, org, project, detail.BuildId, detail.RunName, buildCount, - detail.ErrorMessage, detail.StackTrace, detail.HelixJob, detail.HelixWorkItem, helixFiles, isDeadletter); + detail.ErrorMessage, detail.StackTrace, detail.HelixJob, detail.HelixWorkItem, helixFiles, isDeadletter, helixExitCode); } /// @@ -660,3 +761,5 @@ static void AppendContext(System.Text.StringBuilder sb, TestDetailInfo info, str return match.Success ? match.Value : null; } } + + diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 48c1ba3..b01c996 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class BuildBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly AzdoClientFactory _clientFactory; private readonly BuildAnalysisService? _analysisService; @@ -53,7 +55,6 @@ private void RunLoop() { while (_position >= 0) { - AnsiConsole.Clear(); var action = Render(_history[_position]); switch (action) @@ -108,26 +109,30 @@ private NavAction RenderBuildList() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Builds[/]"); - if (_filter.IsActive) - { - AnsiConsole.MarkupLine($"Filter: {Markup.Escape(_filter.ToString())}"); - } - else - { - AnsiConsole.MarkupLine("[dim]Filter: (none)[/]"); - } - AnsiConsole.WriteLine(); - var builds = QueryBuilds(); + var filterText = _filter.IsActive + ? $"Filter: {Markup.Escape(_filter.ToString())}" + : "[dim]Filter: (none)[/]"; + var context = builds.Count > 0 + ? $"{filterText} [dim]({builds.Count} builds)[/]" + : filterText; + if (builds.Count == 0) { - AnsiConsole.MarkupLine(_filter.IsActive + var emptyMsg = _filter.IsActive ? "[yellow]No builds match the current filter.[/]" - : "[yellow]No builds ingested yet.[/]"); - AnsiConsole.MarkupLine(" [blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + : "[yellow]No builds ingested yet.[/]"; + + _ui.RenderDetailPanel( + ["Builds"], + context, + () => _ui.RenderPanelLine(emptyMsg), + PanelRenderer.BuildCommandBarString(new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + })); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) @@ -154,14 +159,11 @@ private NavAction RenderBuildList() return NavAction.Back.Instance; } - AnsiConsole.MarkupLine($"[dim]{builds.Count} builds[/]"); - AnsiConsole.WriteLine(); - var choices = builds.Select(b => { var resultIcon = b.Result switch { - "succeeded" => "[green]✓[/]", + "succeeded" => "[green]+[/]", "failed" => "[red]X[/]", "partiallySucceeded" => "[yellow]![/]", "canceled" => "[dim]-[/]", @@ -176,20 +178,23 @@ private NavAction RenderBuildList() _lastBuilds = builds; - var hotkeys = _filter.IsActive - ? "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]C[/]lear [blue]H[/]elp" - : "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]H[/]elp"; + var commands = new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + new("Help", ConsoleKey.H, -3), + }; + if (_filter.IsActive) + { + commands.Add(new("Clear", ConsoleKey.C, -4)); + } - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, - extraKeys: new Dictionary { - { ConsoleKey.E, -5 }, - { ConsoleKey.F, -2 }, - { ConsoleKey.H, -3 }, - { ConsoleKey.C, -4 }, - }, - useMarkup: true, - startIndex: _selectedBuildIndex, - hotkeys: hotkeys); + var selected = _ui.SelectInPanel( + ["Builds"], + context, + choices, + commands, + startIndex: _selectedBuildIndex); if (selected == -5) // E pressed { @@ -213,7 +218,9 @@ private NavAction RenderBuildList() continue; } if (selected < 0) + { return NavAction.Back.Instance; + } _selectedBuildIndex = selected; var b2 = builds[selected]; @@ -304,115 +311,131 @@ LIMIT 50 private void EditFilter() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Edit Filter[/]"); - AnsiConsole.MarkupLine("[dim]Syntax: repo:VALUE def:VALUE num:VALUE result:VALUE pr:NUMBER[/]"); - AnsiConsole.MarkupLine("[dim]Examples: repo:roslyn result:failed repo:dotnet/* def:*-CI[/]"); - AnsiConsole.MarkupLine("[dim]Append ! for exact match: repo:dotnet/roslyn![/]"); - AnsiConsole.MarkupLine("[dim]Press Esc to cancel[/]"); - AnsiConsole.WriteLine(); - if (_filter.IsActive) - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.Markup("[blue]> [/]"); - - var buffer = new System.Text.StringBuilder(); + var currentValue = _filter.IsActive ? _filter.ToString() : null; + var result = _ui.PromptInPanel( + ["Builds", "Edit Filter"], + "Enter filter expression (e.g. repo:roslyn def:ci result:failed)", + currentValue); + + if (result is not null) + { + _filter.ParseExpression(result); + SaveFilter(); + } + } + + private void ShowFilterMenu() + { while (true) { - var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.Escape) - return; // keep existing filter - if (key.Key == ConsoleKey.Enter) - { - AnsiConsole.WriteLine(); - var input = buffer.ToString().Trim(); - if (!string.IsNullOrEmpty(input)) - _filter.ParseExpression(input); - SaveFilter(); - return; - } - if (key.Key == ConsoleKey.Backspace) + var commands = new List { - if (buffer.Length > 0) + new("Repository", ConsoleKey.R, 1), + new("Definition", ConsoleKey.D, 2), + new("Build ID", ConsoleKey.I, 3), + new("Outcome", ConsoleKey.O, 4), + new("Kind", ConsoleKey.K, 5), + new("Branch", ConsoleKey.B, 6), + new("PR number", ConsoleKey.P, 7), + new("Clear", ConsoleKey.C, 8), + }; + + _ui.RenderDetailPanel( + ["Builds", "Filter"], + null, + () => { - buffer.Remove(buffer.Length - 1, 1); - Console.Write("\b \b"); - } - continue; - } - if (key.KeyChar >= 32) + _ui.RenderPanelLine("Filter available builds by repository, definition, kind, branch, etc."); + _ui.RenderEmptyLine(); + + if (_filter.IsActive) + { + _ui.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + } + else + { + _ui.RenderPanelLine("[dim]No filter active[/]"); + } + + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + _ui.RenderPanelLine("[dim]Example: repo:roslyn def:*-CI result:failed branch:main[/]"); + }, + PanelRenderer.BuildCommandBarString(commands)); + + var key = Console.ReadKey(true); + switch (key.Key) { - buffer.Append(key.KeyChar); - Console.Write(key.KeyChar); + case ConsoleKey.R: + _filter.RepoPattern = PromptFilterField("Repository pattern (e.g. roslyn, dotnet/*)"); + SaveFilter(); + continue; + case ConsoleKey.D: + _filter.DefinitionPattern = PromptFilterField("Definition pattern (e.g. ci, roslyn-CI*)"); + SaveFilter(); + continue; + case ConsoleKey.I: + _filter.IdPattern = PromptFilterField("Build ID pattern (e.g. 1423*, 142333)"); + SaveFilter(); + continue; + case ConsoleKey.O: + _filter.ResultPattern = PromptResultFilter(); + SaveFilter(); + continue; + case ConsoleKey.K: + _filter.KindPattern = BrowserUI.PromptKindFilter(_ui); + SaveFilter(); + continue; + case ConsoleKey.B: + _filter.BranchPattern = PromptFilterField("Branch pattern (e.g. main, release/*)"); + SaveFilter(); + continue; + case ConsoleKey.P: + _filter.PrNumber = PromptPrNumber(); + SaveFilter(); + continue; + case ConsoleKey.C: + _filter.Clear(); + SaveFilter(); + continue; + case ConsoleKey.Escape: + return; + default: + continue; } } } - private void ShowFilterMenu() + private string? PromptFilterField(string prompt) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Set Filter[/]"); - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]R[/]epository"); - AnsiConsole.MarkupLine(" [blue]D[/]efinition"); - AnsiConsole.MarkupLine(" Build [blue]I[/]D"); - AnsiConsole.MarkupLine(" [blue]O[/]utcome (failed, succeeded, partiallySucceeded)"); - AnsiConsole.MarkupLine(" [blue]K[/]ind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]B[/]ranch"); - AnsiConsole.MarkupLine(" [blue]P[/]R number"); - AnsiConsole.MarkupLine(" [blue]C[/]lear all filters"); - AnsiConsole.MarkupLine(" [blue]Esc[/] Cancel"); - - var key = Console.ReadKey(true); - switch (key.Key) - { - case ConsoleKey.R: - _filter.RepoPattern = BrowserUI.PromptPattern("Repository pattern (e.g. roslyn, dotnet/*):"); - break; - case ConsoleKey.D: - _filter.DefinitionPattern = BrowserUI.PromptPattern("Definition pattern (e.g. ci, roslyn-CI*):"); - break; - case ConsoleKey.I: - _filter.IdPattern = BrowserUI.PromptPattern("Build ID pattern (e.g. 1423*, 142333):"); - break; - case ConsoleKey.O: - _filter.ResultPattern = PromptResultFilter(); - break; - case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); - break; - case ConsoleKey.B: - _filter.BranchPattern = BrowserUI.PromptPattern("Branch pattern (e.g. main, release/*):"); - break; - case ConsoleKey.P: - _filter.PrNumber = PromptPrNumber(); - break; - case ConsoleKey.C: - _filter.Clear(); - break; - } - SaveFilter(); + return _ui.PromptInPanel(["Builds", "Filter"], prompt); } /// /// Selection menu for outcome filter. Returns null if cancelled. /// - private static string? PromptResultFilter() + private string? PromptResultFilter() { - AnsiConsole.WriteLine(); - var choices = new[] { "all", "failed", "succeeded", "partiallySucceeded" }; - var selected = BrowserUI.SelectWithEscape("Select outcome:", choices.ToList(), pageSize: 5); - if (selected < 0) return null; // cancelled - return choices[selected] == "all" ? null : choices[selected]; + var outcomes = new List { "all", "failed", "succeeded", "partiallySucceeded" }; + var commands = new List(); + var selected = _ui.SelectInPanel( + ["Builds", "Filter", "Outcome"], + "[dim]Select build outcome to filter on[/]", + outcomes, + commands); + if (selected < 0) + { + return null; + } + return outcomes[selected] == "all" ? null : outcomes[selected]; } /// /// Prompts the user to enter a PR number. Returns null if cancelled or invalid. /// - private static int? PromptPrNumber() + private int? PromptPrNumber() { - AnsiConsole.WriteLine(); - var raw = BrowserUI.PromptPattern("PR number (e.g. 12345):"); + var raw = _ui.PromptInPanel(["Builds", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -420,33 +443,36 @@ private void ShowFilterMenu() return int.TryParse(raw, out var pr) ? pr : null; } - private static void ShowFilterHelp() + private void ShowFilterHelp() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Filter Help[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Quick filter (E):[/]"); - AnsiConsole.MarkupLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Matching (default: contains / LIKE):[/]"); - AnsiConsole.MarkupLine(" [dim]ros → matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/* → matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); - AnsiConsole.MarkupLine(" [dim]14* → matches build numbers starting with '14'[/]"); - AnsiConsole.MarkupLine(" [dim]*-CI → matches definition names ending with '-CI'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Exact match (append !):[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Filter prefixes:[/]"); - AnsiConsole.MarkupLine(" [blue]repo:[/] Repository name"); - AnsiConsole.MarkupLine(" [blue]def:[/] Definition/pipeline name"); - AnsiConsole.MarkupLine(" [blue]id:[/] Build ID (e.g. 1423*, 142333)"); - AnsiConsole.MarkupLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); - AnsiConsole.MarkupLine(" [blue]kind:[/] Build kind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]branch:[/] Source branch (e.g. main, release/*)"); - AnsiConsole.MarkupLine(" [blue]pr:[/] PR number (e.g. 12345)"); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Multiple filters combine with AND.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to continue...[/]"); + _ui.RenderDetailPanel( + ["Builds", "Filter Help"], + null, + () => + { + _ui.RenderPanelLine("[bold]Quick filter (E):[/]"); + _ui.RenderPanelLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + _ui.RenderPanelLine(" [dim]ros - matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); + _ui.RenderPanelLine(" [dim]dotnet/* - matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); + _ui.RenderPanelLine(" [dim]*-CI - matches definition names ending with '-CI'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Exact match (append !):[/]"); + _ui.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Filter prefixes:[/]"); + _ui.RenderPanelLine(" [blue]repo:[/] Repository name"); + _ui.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + _ui.RenderPanelLine(" [blue]id:[/] Build ID"); + _ui.RenderPanelLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); + _ui.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + _ui.RenderPanelLine(" [blue]branch:[/] Source branch"); + _ui.RenderPanelLine(" [blue]pr:[/] PR number"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + }, + "[blue]Esc[/] Back"); Console.ReadKey(true); } @@ -454,8 +480,6 @@ private static void ShowFilterHelp() private NavAction RenderBuildDetail(BuildDetailPage page) { - Console.SetCursorPosition(0, 0); - // Header info from DB var buildInfo = _db.WithCommand(cmd => { @@ -488,7 +512,11 @@ FROM builds if (!buildInfo.Found) { - AnsiConsole.MarkupLine("[red]Build not found.[/]"); + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId}"], + null, + () => _ui.RenderPanelLine("[red]Build not found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -503,218 +531,262 @@ FROM builds var url = $"https://dev.azure.com/{Uri.EscapeDataString(page.Org)}/{Uri.EscapeDataString(page.Project)}/_build/results?buildId={page.BuildId}"; - // Build header - var headerTable = new Table().Border(TableBorder.Rounded).Expand(); - headerTable.AddColumn(new TableColumn("").NoWrap()); - headerTable.AddColumn(new TableColumn("")); - headerTable.HideHeaders(); - - headerTable.AddRow("[bold]Build[/]", $"#{page.BuildId} — {defName} {buildNumber}"); - headerTable.AddRow("[bold]Result[/]", BrowserUI.FormatResult(result)); - if (prNumber is not null && repoName is not null) - { - var prUrl = $"https://github.com/{repoName}/pull/{prNumber}"; - - // Try to get cached PR info - var prInfo = _db.WithCommand(cmd => - { - cmd.CommandText = "SELECT title, author FROM pull_requests WHERE repository = @repo AND pr_number = @pr"; - cmd.Parameters.AddWithValue("@repo", repoName); - cmd.Parameters.AddWithValue("@pr", prNumber); - using var reader = cmd.ExecuteReader(); - if (reader.Read() && !reader.IsDBNull(0)) - { - return (Found: true, Title: reader.GetString(0), Author: reader.IsDBNull(1) ? string.Empty : reader.GetString(1)); - } - - return (Found: false, Title: string.Empty, Author: string.Empty); - }); - - if (prInfo.Found) - { - // Truncate title to fit: "PR Info" col + "#123 author " leaves room for title - var prefix = $"#{prNumber} {prInfo.Author} "; - var maxTitleLen = Math.Max(10, Console.WindowWidth - prefix.Length - 20); - var truncatedTitle = prInfo.Title.Length > maxTitleLen ? prInfo.Title[..maxTitleLen] + "..." : prInfo.Title; - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); - } - else - { - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber}"); - } - headerTable.AddRow("[bold]PR Url[/]", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); - } - else if (prNumber is not null) - { - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber}"); - } - else - { - headerTable.AddRow("[bold]Branch[/]", branch); - } - if (finishTime is not null) - headerTable.AddRow("[bold]Finished[/]", BrowserUI.FormatTime(finishTime)); - headerTable.AddRow("[bold]URL[/]", BrowserUI.FormatLink(url, url)); - - // Ingestion status line: Timeline, Tests, Helix with status icons + // Ingestion status var taskStatuses = GetIngestionTaskStatuses(page.Org, page.Project, page.BuildId); var taskStatusMap = taskStatuses.ToDictionary(t => t.TaskType, t => t); string TaskIcon(string taskType) { if (!taskStatusMap.TryGetValue(taskType, out var t)) + { return "[yellow]...[/]"; + } return t.Status switch { - "complete" => "[green]✓[/]", + "complete" => "[green]+[/]", "running" => "[blue]...[/]", "failed" => $"[yellow]X retry {t.Attempts}/5[/]", "abandoned" => "[red]abandoned[/]", _ => "[yellow]...[/]", }; } - headerTable.AddRow("[bold]Data[/]", - $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); - - AnsiConsole.Write(headerTable); - AnsiConsole.WriteLine(); var timelineStatus = taskStatusMap.GetValueOrDefault("timeline").Status; var testsStatus = taskStatusMap.GetValueOrDefault("tests").Status; - // Failed jobs section (from DB timeline issues, when timeline is ingested) - if (timelineStatus == "complete") - { - var failedJobNames = _db.WithCommand(cmd => - { - cmd.CommandText = """ - SELECT DISTINCT parent_name - FROM build_timeline_issues - WHERE organization = @org AND build_id = @buildId - AND parent_name IS NOT NULL AND issue_type = 'error' - ORDER BY parent_name - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - - var failedJobNames = new List(); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - failedJobNames.Add(reader.GetString(0)); - } - return failedJobNames; - }); + var canForward = _position < _history.Count - 1; + var buildIndex = _lastBuilds.FindIndex(b => b.BuildId == page.BuildId && b.Org == page.Org && b.Project == page.Project); + var canNext = buildIndex >= 0 && buildIndex < _lastBuilds.Count - 1; + var canPrev = buildIndex > 0; - AnsiConsole.MarkupLine("[bold underline]Failed Jobs[/]"); - if (failedJobNames.Count > 0) - { - foreach (var jobName in failedJobNames.Take(15)) - AnsiConsole.MarkupLine($" [red]X[/] {Markup.Escape(jobName)}"); - } - else - { - AnsiConsole.MarkupLine(" [green]No failed jobs[/]"); - } - AnsiConsole.WriteLine(); + var detailCommands = new List + { + new("Tests", ConsoleKey.T, -10), + new("Jobs", ConsoleKey.J, -11), + new("Helix", ConsoleKey.H, -12), + new("Analysis", ConsoleKey.A, -13), + }; + if (canForward) + { + detailCommands.Add(new("Forward", ConsoleKey.F, -14)); } - - // Failed tests section - AnsiConsole.MarkupLine("[bold underline]Failed Tests[/]"); - if (testsStatus != "complete") + if (canNext) { - AnsiConsole.MarkupLine(" [yellow]Tests not available yet[/]"); + detailCommands.Add(new("Next", ConsoleKey.N, -15)); } - else + if (canPrev) { - var failedTests = _db.WithCommand(cmd => + detailCommands.Add(new("Prev", ConsoleKey.P, -16)); + } + + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId} {defName}"], + $"{BrowserUI.FormatResult(result)} {BrowserUI.FormatTime(finishTime)}", + () => { - cmd.CommandText = """ - SELECT r.run_name, tr.test_case_title, tr.error_message - FROM test_results tr - JOIN test_runs r ON tr.organization = r.organization AND tr.run_id = r.run_id - WHERE r.organization = @org AND r.project = @proj AND r.build_id = @buildId - AND tr.outcome = 'Failed' - ORDER BY r.run_name, tr.test_case_title - LIMIT 50 - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - - var failedTests = new List<(string RunName, string Title, string Error)>(); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + // Build info fields + _ui.RenderField("Build", $"#{page.BuildId} — {defName} {buildNumber}"); + _ui.RenderField("Result", BrowserUI.FormatResult(result)); + if (prNumber is not null && repoName is not null) + { + var prUrl = $"https://github.com/{repoName}/pull/{prNumber}"; + var prInfo = _db.WithCommand(cmd => + { + cmd.CommandText = "SELECT title, author FROM pull_requests WHERE repository = @repo AND pr_number = @pr"; + cmd.Parameters.AddWithValue("@repo", repoName); + cmd.Parameters.AddWithValue("@pr", prNumber); + using var reader = cmd.ExecuteReader(); + if (reader.Read() && !reader.IsDBNull(0)) + { + return (Found: true, Title: reader.GetString(0), Author: reader.IsDBNull(1) ? string.Empty : reader.GetString(1)); + } + return (Found: false, Title: string.Empty, Author: string.Empty); + }); + + if (prInfo.Found) + { + var prefix = $"#{prNumber} {prInfo.Author} "; + var maxTitleLen = Math.Max(10, _ui.ContentWidth - prefix.Length - 20); + var truncatedTitle = prInfo.Title.Length > maxTitleLen ? prInfo.Title[..maxTitleLen] + "..." : prInfo.Title; + _ui.RenderField("PR", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); + } + else + { + _ui.RenderField("PR", $"#{prNumber}"); + } + _ui.RenderField("PR URL", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); + } + else if (prNumber is not null) { - var runName = reader.GetString(0); - var title = reader.GetString(1); - var error = reader.IsDBNull(2) ? string.Empty : reader.GetString(2); - failedTests.Add((runName, title, error)); + _ui.RenderField("PR", $"#{prNumber}"); } - return failedTests; - }); + else + { + _ui.RenderField("Branch", branch); + } + if (finishTime is not null) + { + _ui.RenderField("Finished", BrowserUI.FormatTime(finishTime)); + } + _ui.RenderField("URL", BrowserUI.FormatLink(url, url)); + _ui.RenderField("Data", $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); + _ui.RenderEmptyLine(); - if (failedTests.Count == 0) - { - AnsiConsole.MarkupLine(" [green]All tests passed[/]"); - } - else - { - foreach (var group in failedTests.GroupBy(t => t.RunName)) + // Failed jobs section + if (timelineStatus == "complete") { - AnsiConsole.MarkupLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); - var shown = 0; - var total = group.Count(); - foreach (var test in group.Take(5)) + var failedJobNames = _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT DISTINCT parent_name + FROM build_timeline_issues + WHERE organization = @org AND build_id = @buildId + AND parent_name IS NOT NULL AND issue_type = 'error' + ORDER BY parent_name + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + + var names = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + names.Add(reader.GetString(0)); + } + return names; + }); + + _ui.RenderSectionTitle("Failed Jobs"); + if (failedJobNames.Count > 0) { - var title = test.Title.Length > 68 ? test.Title[..65] + "..." : test.Title; - var error = test.Error; - if (error.Length > 60) error = error[..57] + "..."; - error = error.ReplaceLineEndings(" "); - AnsiConsole.MarkupLine($" [red]X[/] {Markup.Escape(title)}"); - if (!string.IsNullOrWhiteSpace(error)) - AnsiConsole.MarkupLine($" [dim]{Markup.Escape(error)}[/]"); - shown++; + foreach (var jobName in failedJobNames.Take(15)) + { + _ui.RenderPanelLine($" [red]X[/] {Markup.Escape(jobName)}"); + } } - if (total > shown) - AnsiConsole.MarkupLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); + else + { + _ui.RenderPanelLine(" [green]No failed jobs[/]"); + } + _ui.RenderEmptyLine(); } - } - } - // Helix work items count - var helixCount = _db.WithCommand(cmd => - { - cmd.CommandText = """ - SELECT COUNT(DISTINCT hw.job_name || '/' || hw.work_item_name) - FROM test_results tr - JOIN test_runs trn ON tr.organization = trn.organization - AND tr.project = trn.project AND tr.run_id = trn.run_id - JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name - AND tr.helix_work_item_name = hw.work_item_name - WHERE trn.organization = @org AND trn.project = @proj AND trn.build_id = @buildId - AND tr.outcome = 'Failed' - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - return Convert.ToInt32(cmd.ExecuteScalar()); - }); - if (helixCount > 0) - { - AnsiConsole.MarkupLine($" [bold]Helix Work Items:[/] {helixCount}"); - } + // Failed tests section + _ui.RenderSectionTitle("Failed Tests"); + if (testsStatus != "complete") + { + _ui.RenderPanelLine(" [yellow]Tests not available yet[/]"); + } + else + { + var failedTests = _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT r.run_name, tr.test_case_title, tr.error_message + FROM test_results tr + JOIN test_runs r ON tr.organization = r.organization AND tr.run_id = r.run_id + WHERE r.organization = @org AND r.project = @proj AND r.build_id = @buildId + AND tr.outcome = 'Failed' + ORDER BY r.run_name, tr.test_case_title + LIMIT 50 + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + + var tests = new List<(string RunName, string Title, string Error)>(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + tests.Add((reader.GetString(0), reader.GetString(1), + reader.IsDBNull(2) ? string.Empty : reader.GetString(2))); + } + return tests; + }); + + if (failedTests.Count == 0) + { + _ui.RenderPanelLine(" [green]All tests passed[/]"); + } + else + { + foreach (var group in failedTests.GroupBy(t => t.RunName)) + { + _ui.RenderPanelLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); + var shown = 0; + var total = group.Count(); + foreach (var test in group.Take(5)) + { + var title = test.Title.Length > 68 ? test.Title[..65] + "..." : test.Title; + var error = test.Error; + if (error.Length > 60) + { + error = error[..57] + "..."; + } + error = error.ReplaceLineEndings(" "); + _ui.RenderPanelLine($" [red]X[/] {Markup.Escape(title)}"); + if (!string.IsNullOrWhiteSpace(error)) + { + _ui.RenderPanelLine($" [dim]{Markup.Escape(error)}[/]"); + } + shown++; + } + if (total > shown) + { + _ui.RenderPanelLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); + } + } + } + } - AnsiConsole.WriteLine(); - var canForward = _position < _history.Count - 1; - var buildIndex = _lastBuilds.FindIndex(b => b.BuildId == page.BuildId && b.Org == page.Org && b.Project == page.Project); - var canNext = buildIndex >= 0 && buildIndex < _lastBuilds.Count - 1; - var canPrev = buildIndex > 0; - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]T[/]ests [blue]J[/]obs [blue]H[/]elix [blue]A[/]nalysis [blue]B[/]ack" + - (canForward ? " [blue]F[/]orward" : "") + - (canNext ? " [blue]N[/]ext" : "") + - (canPrev ? " [blue]P[/]rev" : "")); + // Helix work items + var helixItems = _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT DISTINCT tr.helix_job_name, tr.helix_work_item_name, hw.state, hw.exit_code, hw.is_deadletter + FROM test_results tr + JOIN test_runs trn ON tr.organization = trn.organization + AND tr.project = trn.project AND tr.run_id = trn.run_id + LEFT JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name + AND tr.helix_work_item_name = hw.work_item_name + WHERE trn.organization = @org AND trn.project = @proj AND trn.build_id = @buildId + AND tr.outcome = 'Failed' + AND tr.helix_job_name IS NOT NULL + ORDER BY tr.helix_job_name, tr.helix_work_item_name + LIMIT 15 + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + + var items = new List<(string Job, string Wi, string? State, int? ExitCode, bool IsDeadletter)>(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + items.Add(( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3), + !reader.IsDBNull(4) && reader.GetInt32(4) != 0)); + } + return items; + }); + + if (helixItems.Count > 0) + { + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Failed Helix Work Items ({helixItems.Count})"); + foreach (var (job, wi, state, exitCode, isDeadletter) in helixItems) + { + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + _ui.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + } + } + }, + PanelRenderer.BuildCommandBarString(detailCommands)); return ReadNavKey(page); } @@ -723,9 +795,6 @@ FROM test_results tr private NavAction RenderTestList(TestListPage page) { - AnsiConsole.MarkupLine($"[bold underline]Failed Tests — Build #{page.BuildId}[/]"); - AnsiConsole.WriteLine(); - var tests = _db.WithCommand(cmd => { var tests = new List<(string RunName, string Title)>(); @@ -751,18 +820,23 @@ FROM test_results tr if (tests.Count == 0) { - AnsiConsole.MarkupLine("[green]No failed tests in this build.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Tests"], + null, + () => _ui.RenderPanelLine("[green]No failed tests in this build.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } // Build grouped display: run name headers are non-selectable, tests are selectable var choices = new List(); - var selectableIndices = new List(); // maps choice index → tests list index + var selectableIndices = new List(); // maps choice index -> tests list index + var skipIndices = new HashSet(); var grouped = tests.Select((t, i) => (t, i)).GroupBy(x => x.t.RunName); foreach (var group in grouped) { + skipIndices.Add(choices.Count); choices.Add($"[bold yellow]{Markup.Escape(group.Key)}[/]"); selectableIndices.Add(-1); // header, not selectable @@ -775,11 +849,19 @@ FROM test_results tr } var totalFailed = tests.Select(t => t.Title).Distinct().Count(); - var selected = BrowserUI.SelectWithEscape($"{totalFailed} failed test(s) across {grouped.Count()} run(s):", - choices, useMarkup: true, skipIndices: selectableIndices.Select((v, i) => (v, i)).Where(x => x.v == -1).Select(x => x.i).ToHashSet()); + var commands = new List(); + + var selected = _ui.SelectInPanel( + ["Builds", $"#{page.BuildId}", "Tests"], + $"[dim]{totalFailed} failed test(s) across {grouped.Count()} run(s)[/]", + choices, + commands, + skipIndices: skipIndices); if (selected < 0) + { return NavAction.Back.Instance; + } var testTitle = tests[selectableIndices[selected]].Title; return new NavAction.Push( @@ -793,41 +875,118 @@ private NavAction RenderTestDetail(TestDetailPage page) var info = BrowserUI.LoadTestDetail(_db, page.Org, page.Project, page.TestName); if (info is null) { - AnsiConsole.MarkupLine("[yellow]No test failure data found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Builds", "Tests", "Detail"], + null, + () => _ui.RenderPanelLine("[yellow]No test failure data found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } - BrowserUI.RenderTestDetail(info); - - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]B[/]uilds with this failure [blue]A[/]gent task [blue]Esc[/] Back"); + var shortTitle = page.TestName.Length > 60 ? page.TestName[..57] + "..." : page.TestName; + var truncate = true; while (true) { - var key = Console.ReadKey(true); - switch (key.Key) + _ui.TruncationEnabled = truncate; + var commands = new List { - case ConsoleKey.B: - return new NavAction.Push(new TestBuildsPage(page.Org, page.Project, page.TestName)); - case ConsoleKey.A: - BrowserUI.CreateAgentTask(_db, info); - return NavAction.Refresh.Instance; - case ConsoleKey.Escape: - return NavAction.Back.Instance; + new("Builds with failure", ConsoleKey.B, -2), + new("Agent task", ConsoleKey.A, -3), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -5), + }; + if (info.HelixJobName is not null) + { + commands.Add(new("Helix", ConsoleKey.H, -4)); + } + + _ui.RenderDetailPanel( + ["Builds", "Tests", Markup.Escape(shortTitle)], + null, + () => BrowserUI.RenderTestDetailInPanel(_ui, info), + PanelRenderer.BuildCommandBarString(commands)); + + while (true) + { + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; + switch (key.Key) + { + case ConsoleKey.B: + _ui.TruncationEnabled = true; + return new NavAction.Push(new TestBuildsPage(page.Org, page.Project, page.TestName)); + case ConsoleKey.A: + BrowserUI.CreateAgentTask(_db, info); + _ui.TruncationEnabled = true; + return NavAction.Refresh.Instance; + case ConsoleKey.H when info.HelixJobName is not null: + ShowHelixWorkItemDetail(info); + _ui.TruncationEnabled = true; + return NavAction.Refresh.Instance; + case ConsoleKey.T: + truncate = !truncate; + break; + case ConsoleKey.Escape: + _ui.TruncationEnabled = true; + return NavAction.Back.Instance; + } + break; // re-render with updated truncate setting } } } + private void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + { + var commands = new List(); + _ui.RenderDetailPanel( + ["Tests", "Helix Work Item"], + null, + () => + { + if (info.IsHelixDeadletter) + { + _ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); + _ui.RenderEmptyLine(); + } + _ui.RenderField("Job", Markup.Escape(info.HelixJobName!)); + if (info.HelixWorkItemName is not null) + { + _ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); + _ui.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + } + if (info.HelixFiles is { Count: > 0 }) + { + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + _ui.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + } + else + { + _ui.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + }, + "[blue]Esc[/] Back"); + while (true) + { + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; + } + } + // ── Test Builds (builds with this failure) ────────────────────── private NavAction RenderTestBuilds(TestBuildsPage page) { var shortTitle = page.TestName.Length > 60 ? page.TestName[..57] + "..." : page.TestName; - AnsiConsole.MarkupLine($"[bold underline]Builds with failure[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(shortTitle)}[/]"); - AnsiConsole.WriteLine(); var builds = _db.WithCommand(cmd => { @@ -867,8 +1026,11 @@ LIMIT 30 if (builds.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No builds found with this test failure.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Builds", "Tests", "Builds with failure"], + null, + () => _ui.RenderPanelLine("[yellow]No builds found with this test failure.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -877,7 +1039,7 @@ LIMIT 30 { var resultIcon = b.Result switch { - "succeeded" => "[green]✓[/]", + "succeeded" => "[green]+[/]", "failed" => "[red]X[/]", "partiallySucceeded" => "[yellow]![/]", _ => "[dim]-[/]", @@ -887,10 +1049,17 @@ LIMIT 30 return $"{resultIcon} {b.BuildId} {Markup.Escape(b.DefinitionName)} {time}{pr}"; }).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, useMarkup: true); + var commands = new List(); + var selected = _ui.SelectInPanel( + ["Builds", "Tests", Markup.Escape(shortTitle), "Builds"], + $"[dim]{builds.Count} build(s) with this failure[/]", + choices, + commands); if (selected < 0) + { return NavAction.Back.Instance; + } var b2 = builds[selected]; return new NavAction.Push(new BuildDetailPage(b2.Org, b2.Project, b2.BuildId)); @@ -937,8 +1106,11 @@ FROM build_timeline_issues if (jobIssues.Count == 0) { - AnsiConsole.MarkupLine("[green]No timeline issues recorded for this build.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Jobs"], + null, + () => _ui.RenderPanelLine("[green]No timeline issues recorded for this build.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -948,55 +1120,75 @@ FROM build_timeline_issues while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Failed Jobs — Build #{page.BuildId}[/]"); - if (errorsOnly) - AnsiConsole.MarkupLine("[dim]Showing errors only[/]"); - AnsiConsole.WriteLine(); - - foreach (var (jobName, issues) in jobIssues) + var commands = new List { - var filtered = errorsOnly - ? issues.Where(i => i.Type == "error").ToList() - : issues; - - if (filtered.Count == 0) - continue; - - var errorCount = issues.Count(i => i.Type == "error"); - var warnCount = issues.Count(i => i.Type == "warning"); - var summary = new List(); - if (errorCount > 0) summary.Add($"[red]{errorCount} error(s)[/]"); - if (warnCount > 0) summary.Add($"[yellow]{warnCount} warning(s)[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); + new(errorsOnly ? "Errors: showing" : "Errors only", ConsoleKey.E, -2), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -3), + }; - foreach (var (type, message) in filtered.Take(10)) + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Jobs"], + errorsOnly ? "[dim]Showing errors only[/]" : null, + () => { - var icon = type == "error" ? "[red]error[/]" : "[yellow]warn[/]"; - var msg = message.ReplaceLineEndings(" "); - if (truncate && msg.Length > 120) - msg = msg[..117] + "..."; - AnsiConsole.MarkupLine($" {icon}: {Markup.Escape(msg)}"); - } - - if (filtered.Count > 10) - AnsiConsole.MarkupLine($" [dim]... and {filtered.Count - 10} more[/]"); - - AnsiConsole.WriteLine(); - } - - // Hotkey menu at the bottom - var errorsLabel = errorsOnly ? "[blue]E[/]rrors: showing" : "[blue]E[/]rrors only"; - var truncateLabel = truncate ? "[blue]T[/]runcate: off" : "[blue]T[/]runcate: on"; - AnsiConsole.MarkupLine($" {errorsLabel} {truncateLabel} [blue]Esc[/] Back"); + foreach (var (jobName, issues) in jobIssues) + { + var filtered = errorsOnly + ? issues.Where(i => i.Type == "error").ToList() + : issues; + + if (filtered.Count == 0) + { + continue; + } + + var errorCount = issues.Count(i => i.Type == "error"); + var warnCount = issues.Count(i => i.Type == "warning"); + var summary = new List(); + if (errorCount > 0) + { + summary.Add($"[red]{errorCount} error(s)[/]"); + } + if (warnCount > 0) + { + summary.Add($"[yellow]{warnCount} warning(s)[/]"); + } + _ui.RenderPanelLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); + + foreach (var (type, message) in filtered.Take(10)) + { + var icon = type == "error" ? "[red]error[/]" : "[yellow]warn[/]"; + var msg = message.ReplaceLineEndings(" "); + if (truncate && msg.Length > 120) + { + msg = msg[..117] + "..."; + } + _ui.RenderPanelLine($" {icon}: {Markup.Escape(msg)}"); + } + + if (filtered.Count > 10) + { + _ui.RenderPanelLine($" [dim]... and {filtered.Count - 10} more[/]"); + } + + _ui.RenderEmptyLine(); + } + }, + PanelRenderer.BuildCommandBarString(commands)); var key = Console.ReadKey(true); if (key.Key is ConsoleKey.Escape or ConsoleKey.B) + { return NavAction.Back.Instance; + } if (key.Key == ConsoleKey.T) + { truncate = !truncate; + } if (key.Key == ConsoleKey.E) + { errorsOnly = !errorsOnly; + } } } @@ -1007,6 +1199,7 @@ private NavAction ReadNavKey(BuildDetailPage page) while (true) { var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; switch (key.Key) { @@ -1049,10 +1242,6 @@ private NavAction ReadNavKey(BuildDetailPage page) private void ShowHelixInfo(BuildDetailPage page) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Helix Work Items — Build #{page.BuildId}[/]"); - AnsiConsole.WriteLine(); - var helixItems = _db.WithCommand(cmd => { cmd.CommandText = """ @@ -1086,32 +1275,35 @@ LIMIT 30 return helixItems; }); - var hasHelix = false; - foreach (var (job, wi, state, exitCode, consoleUri, isDeadletter) in helixItems) - { - hasHelix = true; - if (isDeadletter) - { - AnsiConsole.MarkupLine($" [bold red]⚠ DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); - } - else + _ui.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Failed Helix Work Items"], + $"[dim]{helixItems.Count} work item(s)[/]", + () => { - var stateInfo = state is not null ? $" [{(exitCode == 0 ? "green" : "red")}]{state} (exit {exitCode})[/]" : ""; - AnsiConsole.MarkupLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); - } + if (helixItems.Count == 0) + { + _ui.RenderPanelLine("[yellow]No Helix work items found for failed tests in this build.[/]"); + return; + } - var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); - AnsiConsole.MarkupLine($" {BrowserUI.FormatLink(url, "Console Log")}"); - } + foreach (var (job, wi, state, exitCode, consoleUri, isDeadletter) in helixItems) + { + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + _ui.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); - if (!hasHelix) + var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); + _ui.RenderPanelLine($" {BrowserUI.FormatLink(url, "Console Log")}"); + } + }, + "[blue]Esc[/] Back"); + while (true) { - AnsiConsole.MarkupLine("[yellow]No Helix work items found for failed tests in this build.[/]"); + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; } - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); - Console.ReadKey(true); } private void ShowAnalysis(BuildDetailPage page) @@ -1319,3 +1511,5 @@ private record BuildRow( int? PrNumber, string? FinishTime, string IngestionStatus = "pending", int DefinitionId = 0, string? RepositoryName = null); } + + diff --git a/src/Tiger/Commands/DashboardCommand.cs b/src/Tiger/Commands/DashboardCommand.cs index dd3d2f5..fa5a917 100644 --- a/src/Tiger/Commands/DashboardCommand.cs +++ b/src/Tiger/Commands/DashboardCommand.cs @@ -9,14 +9,16 @@ namespace Tiger.Commands; /// public sealed class DashboardCommand : AsyncCommand { - private const string MenuStatus = "Status"; - private const string MenuBuilds = "Builds"; - private const string MenuTests = "Tests"; - private const string MenuHealth = "Health"; - private const string MenuAnalysis = "Analysis"; - private const string MenuAgents = "Agents"; - private const string MenuConfig = "Configuration"; - private const string MenuQuit = "Quit"; + private readonly PanelRenderer _ui = PanelRenderer.Create(); + + private const int MenuBuilds = 0; + private const int MenuTests = 1; + private const int MenuHealth = 2; + private const int MenuAnalysis = 3; + private const int MenuAgents = 4; + private const int MenuConfig = 5; + private const int MenuStatus = 6; + private const int MenuQuit = 7; protected override async Task ExecuteAsync(CommandContext context, CancellationToken ct) { @@ -70,95 +72,28 @@ protected override async Task ExecuteAsync(CommandContext context, Cancella return 0; } - private static void RenderBanner() - { - AnsiConsole.Write(new FigletText("tiger").Color(Color.Orange1)); - AnsiConsole.MarkupLine("[dim]CI/CD Infrastructure Management[/]"); - AnsiConsole.WriteLine(); - } - - private static async Task RunMenuLoopAsync( + private async Task RunMenuLoopAsync( TigerContext tigerContext, TigerDatabase db, AzdoClientFactory clientFactory, BuildBackfillService backfill, BuildAnalysisService analysisAgent, ServiceLog serviceLog, CancellationToken ct) { - var menuLabels = new[] + var commands = new List { - $"[blue]B[/]uilds", - $"[blue]T[/]ests", - $"[blue]H[/]ealth", - $"[blue]A[/]nalysis", - $"A[blue]g[/]ents", - $"[blue]C[/]onfiguration", - $"[blue]S[/]tatus", - $"[blue]Q[/]uit", + new("Builds", ConsoleKey.B, MenuBuilds), + new("Tests", ConsoleKey.T, MenuTests), + new("Health", ConsoleKey.H, MenuHealth), + new("Analysis", ConsoleKey.A, MenuAnalysis), + new("Agents", ConsoleKey.G, MenuAgents), + new("Config", ConsoleKey.C, MenuConfig), + new("Status", ConsoleKey.S, MenuStatus), + new("Quit", ConsoleKey.Q, MenuQuit), }; - var selected = 0; while (!ct.IsCancellationRequested) { - AnsiConsole.Clear(); - RenderBanner(); - - AnsiConsole.MarkupLine("[bold]What would you like to do?[/]"); - for (var i = 0; i < menuLabels.Length; i++) - { - if (i == selected) - { - AnsiConsole.MarkupLine($" [blue]>[/] {menuLabels[i]}"); - } - else - { - AnsiConsole.MarkupLine($" {menuLabels[i]}"); - } - } - AnsiConsole.MarkupLine(" [blue]↑↓[/] Navigate [blue]Enter[/] Select"); - - var key = Console.ReadKey(true); - - // Hotkeys - var hotkey = char.ToUpperInvariant(key.KeyChar) switch - { - 'B' => 0, 'T' => 1, 'H' => 2, 'A' => 3, 'G' => 4, 'C' => 5, 'S' => 6, 'Q' => 7, - _ => -1, - }; - if (hotkey >= 0) - { - selected = hotkey; - } - else - { - switch (key.Key) - { - case ConsoleKey.UpArrow: - selected = (selected - 1 + menuLabels.Length) % menuLabels.Length; - continue; - case ConsoleKey.DownArrow: - selected = (selected + 1) % menuLabels.Length; - continue; - case ConsoleKey.Enter: - break; - case ConsoleKey.Escape: - return; - default: - continue; - } - } - - var choice = selected switch - { - 0 => MenuBuilds, - 1 => MenuTests, - 2 => MenuHealth, - 3 => MenuAnalysis, - 4 => MenuAgents, - 5 => MenuConfig, - 6 => MenuStatus, - 7 => MenuQuit, - _ => MenuQuit, - }; + var choice = _ui.ShowMainMenu(commands); switch (choice) { @@ -196,6 +131,7 @@ private static async Task RunMenuLoopAsync( } break; case MenuQuit: + case -1: return; } } @@ -206,26 +142,23 @@ private static async Task RunMenuLoopAsync( /// Hotkeys: E = toggle errors only, Escape = return to menu, /// Up/Down = scroll, End = jump to latest (live tail). /// - private static async Task ShowLiveStatusAsync(ServiceLog serviceLog, CancellationToken ct) + private async Task ShowLiveStatusAsync(ServiceLog serviceLog, CancellationToken ct) { var errorsOnly = false; - var scrollOffset = 0; // 0 = live tail (showing latest), >0 = scrolled back N entries - var maxVisible = Math.Max(Console.WindowHeight - 5, 10); + var scrollOffset = 0; + var maxVisible = Math.Max(Console.WindowHeight - 10, 10); void Render() { - AnsiConsole.Clear(); - var filterLabel = errorsOnly ? " [yellow](errors only)[/]" : ""; - var scrollLabel = scrollOffset > 0 ? $" [dim](scrolled back {scrollOffset})[/]" : " [dim](live)[/]"; - AnsiConsole.MarkupLine($"[bold underline]Service Log[/]{filterLabel}{scrollLabel}"); - AnsiConsole.WriteLine(); + var filterLabel = errorsOnly ? "[yellow](errors only)[/]" : ""; + var scrollLabel = scrollOffset > 0 ? $"[dim](scrolled back {scrollOffset})[/]" : "[dim](live)[/]"; + var context = $"{filterLabel} {scrollLabel}".Trim(); var all = serviceLog.GetRecent(500); var filtered = errorsOnly ? all.Where(e => e.Level is ServiceLogLevel.Error or ServiceLogLevel.Warning).ToList() : all; - // Apply scroll offset from the end var end = filtered.Count - scrollOffset; if (end < 0) { @@ -234,9 +167,37 @@ void Render() var start = Math.Max(0, end - maxVisible); var visible = filtered.Skip(start).Take(end - start).ToList(); - RenderLogEntries(visible); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]E[/]rrors toggle [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); + _ui.RenderDetailPanel( + ["Status", "Service Log"], + context, + () => + { + if (visible.Count == 0) + { + _ui.RenderPanelLine("[dim]No log entries yet...[/]"); + } + else + { + foreach (var entry in visible) + { + var time = entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); + var levelColor = entry.Level switch + { + ServiceLogLevel.Success => "green", + ServiceLogLevel.Warning => "yellow", + ServiceLogLevel.Error => "red", + _ => "blue", + }; + var service = Markup.Escape(entry.Service); + var message = Markup.Escape(entry.Message); + _ui.RenderPanelLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); + } + } + }, + PanelRenderer.BuildCommandBarString(new List + { + new("Errors toggle", ConsoleKey.E, -2), + }) + " [blue]Up/Dn[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); } Render(); @@ -306,28 +267,6 @@ void OnEntry() serviceLog.EntryAdded -= OnEntry; } } +} - private static void RenderLogEntries(List entries) - { - if (entries.Count == 0) - { - AnsiConsole.MarkupLine("[dim]No log entries yet...[/]"); - return; - } - foreach (var entry in entries) - { - var time = entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); - var levelColor = entry.Level switch - { - ServiceLogLevel.Success => "green", - ServiceLogLevel.Warning => "yellow", - ServiceLogLevel.Error => "red", - _ => "blue", - }; - var service = Markup.Escape(entry.Service); - var message = Markup.Escape(entry.Message); - AnsiConsole.MarkupLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); - } - } -} diff --git a/src/Tiger/Commands/HealthCommand.cs b/src/Tiger/Commands/HealthCommand.cs index 40d9057..deadda7 100644 --- a/src/Tiger/Commands/HealthCommand.cs +++ b/src/Tiger/Commands/HealthCommand.cs @@ -9,6 +9,8 @@ namespace Tiger.Commands; /// public sealed class HealthCommand : AsyncCommand { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + public async Task RunAsync(CancellationToken ct) { await ExecuteAsync(null!, ct); @@ -36,24 +38,22 @@ protected override Task ExecuteAsync(Spectre.Console.Cli.CommandContext con /// /// Top-level page: list of repo + pipeline combos that have health reports. /// - private static void ShowCombosPage(HealthAgentService agent) + private void ShowCombosPage(HealthAgentService agent) { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Tiger Health Reports[/]"); - AnsiConsole.WriteLine(); - var runs = agent.GetRecentRuns(); if (runs.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to exit...[/]"); + _ui.RenderDetailPanel( + ["Health"], + null, + () => _ui.RenderPanelLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } - // Get distinct combos var combos = runs .Select(r => (r.Repository, r.Definition)) .Distinct() @@ -61,9 +61,15 @@ private static void ShowCombosPage(HealthAgentService agent) var items = combos.Select(c => $"{c.Repository} / {c.Definition}").ToList(); - var selected = BrowserUI.SelectWithEscape("Select a pipeline:", items); + var selected = _ui.SelectInPanel( + ["Health"], + $"[dim]{combos.Count} pipeline(s)[/]", + items, + new List()); if (selected < 0) + { return; + } var (repo, def) = combos[selected]; ShowStatePage(agent, repo, def); @@ -74,45 +80,46 @@ private static void ShowCombosPage(HealthAgentService agent) /// Second level: shows the current state-of-the-build for a combo. /// User can drill into individual agent runs from here. /// - private static void ShowStatePage(HealthAgentService agent, string repository, string definition) + private void ShowStatePage(HealthAgentService agent, string repository, string definition) { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]{Markup.Escape(repository)} / {Markup.Escape(definition)}[/]"); - AnsiConsole.WriteLine(); - var state = agent.GetCurrentState(repository, definition); - if (state is not null) - { - MarkdownRenderer.Render(state); - } - else - { - AnsiConsole.MarkupLine("[dim]No state summary available yet.[/]"); - } - - AnsiConsole.WriteLine(); - var menuItems = new List - { - $"[blue]R[/]e-run health analysis", - $"[blue]G[/]ist (create public)", - $"[blue]V[/]iew agent runs", - }; - - var extraKeys = new Dictionary - { - [ConsoleKey.R] = 0, - [ConsoleKey.G] = 1, - [ConsoleKey.V] = 2, - }; - - var menuChoice = BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); - - switch (menuChoice) + _ui.RenderDetailPanel( + ["Health", $"{Markup.Escape(repository)} / {Markup.Escape(definition)}"], + null, + () => + { + if (state is not null) + { + // Render markdown content line-by-line inside panel + var lines = state.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in lines.Take(30)) + { + _ui.RenderPanelLine(Markup.Escape(line)); + } + if (lines.Length > 30) + { + _ui.RenderPanelLine($"[dim]... ({lines.Length - 30} more lines)[/]"); + } + } + else + { + _ui.RenderPanelLine("[dim]No state summary available yet.[/]"); + } + }, + PanelRenderer.BuildCommandBarString(new List + { + new("Re-run", ConsoleKey.R, -2), + new("Gist", ConsoleKey.G, -3), + new("View runs", ConsoleKey.V, -4), + })); + + var key = Console.ReadKey(true); + switch (key.Key) { - case 0: + case ConsoleKey.R: AnsiConsole.MarkupLine("[dim]Running health analysis...[/]"); try { @@ -124,13 +131,13 @@ private static void ShowStatePage(HealthAgentService agent, string repository, s AnsiConsole.MarkupLine($"[red]Analysis failed: {Markup.Escape(ex.Message)}[/]"); } break; - case 1: + case ConsoleKey.G: CreateGist(repository, definition, state); break; - case 2: + case ConsoleKey.V: ShowRunsPage(agent, repository, definition); break; - default: + case ConsoleKey.Escape: return; } } @@ -194,64 +201,67 @@ private static void CreateGist(string repository, string definition, string? mar /// /// Third level: list of individual agent runs for a combo, most recent first. /// - private static void ShowRunsPage(HealthAgentService agent, string repository, string definition) + private void ShowRunsPage(HealthAgentService agent, string repository, string definition) { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Agent Runs — {Markup.Escape(repository)} / {Markup.Escape(definition)}[/]"); - AnsiConsole.WriteLine(); - var runs = agent.GetRecentRuns(repository, definition); if (runs.Count == 0) { - AnsiConsole.MarkupLine("[dim]No runs found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Health", $"{Markup.Escape(repository)}", "Runs"], + null, + () => _ui.RenderPanelLine("[dim]No runs found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } var items = runs.Select(r => r.Timestamp.Replace("_", " ")).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a run:", items); + var selected = _ui.SelectInPanel( + ["Health", $"{Markup.Escape(repository)}", "Runs"], + $"[dim]{runs.Count} run(s)[/]", + items, + new List()); if (selected < 0) + { return; + } ShowRunDetail(runs[selected]); } } - /// - /// Fourth level: full log of a single agent run. - /// - private static void ShowRunDetail(HealthRunInfo run) + private void ShowRunDetail(HealthRunInfo run) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Health Report — {Markup.Escape(run.Timestamp.Replace("_", " "))}[/]"); - AnsiConsole.WriteLine(); - - if (File.Exists(run.LogPath)) - { - var content = File.ReadAllText(run.LogPath); - MarkdownRenderer.Render(content); - } - else - { - AnsiConsole.MarkupLine("[red]Log file not found.[/]"); - } - - AnsiConsole.WriteLine(); - - var menuItems = new List - { - $"[blue]B[/]ack", - }; - - var extraKeys = new Dictionary - { - [ConsoleKey.B] = 0, - }; + _ui.RenderDetailPanel( + ["Health", "Run", Markup.Escape(run.Timestamp.Replace("_", " "))], + null, + () => + { + if (File.Exists(run.LogPath)) + { + var content = File.ReadAllText(run.LogPath); + var lines = content.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in lines.Take(40)) + { + _ui.RenderPanelLine(Markup.Escape(line)); + } + if (lines.Length > 40) + { + _ui.RenderPanelLine($"[dim]... ({lines.Length - 40} more lines)[/]"); + } + } + else + { + _ui.RenderPanelLine("[red]Log file not found.[/]"); + } + }, + "[blue]Esc[/] Back"); - BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); + Console.ReadKey(true); } } + + diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs new file mode 100644 index 0000000..8933545 --- /dev/null +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -0,0 +1,829 @@ +using Spectre.Console; + +namespace Tiger.Commands; + +/// +/// Represents a single command in the command bar. +/// +public record CommandBarItem(string Label, ConsoleKey Hotkey, int ReturnValue); + +/// +/// Renders and manages the "command and control" panel UI. Uses +/// for all output, input, and cursor operations — fully testable with Spectre.Console.Testing. +/// +/// Structure: +/// ╔═══════════════════════════════════════════════════╗ +/// ║ TIGER > Section > Subsection ║ +/// ║ Context line (filter, counts, etc.) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Content area (list or detail) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Command bar (focusable via Tab) ║ +/// ╚═══════════════════════════════════════════════════╝ +/// +public class PanelRenderer +{ + private const char TopLeft = '╔'; + private const char TopRight = '╗'; + private const char BottomLeft = '╚'; + private const char BottomRight = '╝'; + private const char Horizontal = '═'; + private const char Vertical = '║'; + private const char MiddleLeft = '╠'; + private const char MiddleRight = '╣'; + private const char Separator = '>'; + private const string BorderStyle = "dim"; + + private readonly IAnsiConsole _console; + + [ThreadStatic] + private static List? _captureTarget; + + // Scroll state for detail panels + private string[]? _lastDetailBreadcrumbs; + private string? _lastDetailContext; + private string? _lastDetailHotkeys; + private List? _lastDetailLines; + private int _lastDetailScrollOffset; + + public PanelRenderer(IAnsiConsole console) + { + _console = console; + } + + /// + /// Creates a renderer backed by the real terminal (AnsiConsole.Console). + /// + public static PanelRenderer Create() => new(AnsiConsole.Console); + + /// + /// The underlying console. + /// + public IAnsiConsole Console => _console; + + /// + /// Terminal width from the console profile. + /// + public int Width => _console.Profile.Width; + + /// + /// Terminal height from the console profile. + /// + public int Height => _console.Profile.Height; + + /// + /// Gets the usable content width (total width - 4 for borders and padding). + /// + public int ContentWidth => Math.Max(40, Width - 4); + + /// + /// When true (default), lines are truncated to fit panel width. + /// When false, lines are allowed to wrap. + /// + public bool TruncationEnabled { get; set; } = true; + + // ── Content building ──────────────────────────────────────────── + + /// + /// Renders a single line inside the panel with vertical borders. + /// During capture phase, buffers the markup string instead. + /// + public void RenderPanelLine(string markupContent) + { + if (_captureTarget is not null) + { + _captureTarget.Add(markupContent); + return; + } + + RenderPanelLineDirect(markupContent); + } + + /// + /// Renders an empty line inside the panel borders. + /// During capture phase, buffers an empty string. + /// + public void RenderEmptyLine() + { + if (_captureTarget is not null) + { + _captureTarget.Add(""); + return; + } + + RenderEmptyLineDirect(); + } + + /// + /// Renders a section title. + /// + public void RenderSectionTitle(string title) + { + RenderPanelLine($"[bold underline]{title}[/]"); + } + + /// + /// Renders a labeled value pair. + /// + public void RenderField(string label, string value) + { + RenderPanelLine($"[bold]{label}:[/] {value}"); + } + + /// + /// Renders the Tiger ASCII art logo. + /// + public void RenderLogo() + { + var writer = new StringWriter(); + var figletConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + ColorSystem = ColorSystemSupport.NoColors, + }); + figletConsole.Write(new FigletText("tiger")); + var figletLines = writer.ToString().Split('\n', StringSplitOptions.None); + + foreach (var line in figletLines) + { + var trimmed = line.TrimEnd('\r', '\n'); + if (trimmed.Length > 0) + { + RenderPanelLine($"[bold orange1]{Markup.Escape(trimmed)}[/]"); + } + } + + RenderEmptyLine(); + RenderPanelLine("[bold orange1]TIGER[/] — CI/CD Infrastructure Management"); + RenderEmptyLine(); + } + + // ── Content Capture ───────────────────────────────────────────── + + /// + /// Executes capturing all RenderPanelLine/RenderEmptyLine + /// calls into a list of markup strings. + /// + public List CaptureContent(Action renderContent) + { + var lines = new List(); + _captureTarget = lines; + try + { + renderContent(); + } + finally + { + _captureTarget = null; + } + return lines; + } + + // ── Layout calculations ───────────────────────────────────────── + + /// + /// Computes how many content lines fit in the detail panel. + /// + public int GetDetailAvailableHeight(bool hasContext) + { + var headerRows = 3 + (hasContext ? 1 : 0); + var footerRows = 3; + return Math.Max(5, Height - headerRows - footerRows); + } + + /// + /// Computes the max page size for list panels. + /// + public int GetListPageSize(bool hasContext) + { + var headerRows = 3 + (hasContext ? 1 : 0); + var footerRows = 3; + var reservedIndicatorRows = 1; + return Math.Max(5, Height - headerRows - footerRows - reservedIndicatorRows); + } + + // ── Hotkey formatting ─────────────────────────────────────────── + + /// + /// Formats a command label with its hotkey in [X] bracket style. + /// + public static string FormatHotkeyLabel(CommandBarItem item) + { + var label = item.Label; + var hotkeyChar = item.Hotkey switch + { + >= ConsoleKey.A and <= ConsoleKey.Z => (char)('A' + (item.Hotkey - ConsoleKey.A)), + _ => '\0' + }; + + if (hotkeyChar == '\0') + { + return label; + } + + for (var i = 0; i < label.Length; i++) + { + if (char.ToUpperInvariant(label[i]) == hotkeyChar) + { + return $"{label[..i]}[blue][[{label[i]}]][/]{label[(i + 1)..]}"; + } + } + + return label; + } + + /// + /// Builds a static hotkey string for detail panel footers. + /// + public static string BuildCommandBarString(List commands) + { + var parts = new List(); + foreach (var cmd in commands) + { + parts.Add(FormatHotkeyLabel(cmd)); + } + return string.Join(" ", parts) + " [blue]Esc[/] Back"; + } + + /// + /// Builds the command bar markup with focus state for interactive panels. + /// + public static string BuildCommandBarMarkup(List commands, int focusedIndex, bool barFocused) + { + if (commands.Count == 0) + { + return "[dim]Up/Dn Navigate Enter Select Esc Back[/]"; + } + + var parts = new List(); + for (var i = 0; i < commands.Count; i++) + { + if (barFocused && i == focusedIndex) + { + parts.Add($"[bold white on blue] {commands[i].Label} [/]"); + } + else + { + parts.Add(FormatHotkeyLabel(commands[i])); + } + } + var barContent = string.Join(" ", parts); + var tabHint = barFocused + ? " [dim]<-> Move Enter Execute Tab Content[/]" + : " [dim]Tab Commands[/]"; + return $"{barContent}{tabHint}"; + } + + // ── Line truncation ───────────────────────────────────────────── + + /// + /// Truncates markup content so its plain-text length fits within the panel width. + /// + public string TruncateToFit(string markupContent) + { + var maxContentWidth = ContentWidth; + var plainText = Markup.Remove(markupContent); + + if (plainText.Length <= maxContentWidth) + { + return markupContent; + } + + var truncated = plainText[..(maxContentWidth - 3)] + "..."; + return Markup.Escape(truncated); + } + + // ── Interactive: Main Menu ─────────────────────────────────────── + + /// + /// Renders the main dashboard with logo and command bar. + /// Returns the selected command's ReturnValue, or -1 on Escape. + /// + public int ShowMainMenu(List commands) + { + var barIndex = 0; + + while (true) + { + Clear(); + _console.Cursor.Show(false); + RenderMainMenuFrame(commands, barIndex); + + while (true) + { + var key = ReadKey(); + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + // Full redraw for simplicity (menu is small) + break; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + break; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + continue; // Unknown key, don't redraw + } + break; // Redraw on arrow keys + } + } + } + + // ── Interactive: Select in Panel ──────────────────────────────── + + /// + /// List selection with a focusable command bar. + /// Returns list index on Enter, command ReturnValue, or -1 on Escape. + /// + public int SelectInPanel(string[] breadcrumbs, string? context, List items, + List commands, int pageSize = 0, + int startIndex = 0, HashSet? skipIndices = null, Action? renderAboveList = null) + { + if (items.Count == 0) + { + return -1; + } + + var maxPageSize = GetListPageSize(context is not null); + if (pageSize <= 0) + { + pageSize = maxPageSize; + } + else + { + pageSize = Math.Min(pageSize, maxPageSize); + } + + var selected = Math.Clamp(startIndex, 0, items.Count - 1); + if (skipIndices is not null) + { + while (selected < items.Count && skipIndices.Contains(selected)) + { + selected++; + } + if (selected >= items.Count) + { + selected = Math.Clamp(startIndex, 0, items.Count - 1); + while (selected > 0 && skipIndices.Contains(selected)) + { + selected--; + } + } + } + + var scrollOffset = Math.Max(0, selected - pageSize + 1); + var visibleCount = Math.Min(pageSize, items.Count); + var barFocused = false; + var barIndex = 0; + var needsFullRedraw = true; + + while (true) + { + if (needsFullRedraw) + { + Clear(); + _console.Cursor.Show(false); + RenderListFrame(breadcrumbs, context, items, selected, scrollOffset, + visibleCount, barFocused, commands, barIndex, renderAboveList); + needsFullRedraw = false; + } + + var key = ReadKey(); + var prevSelected = selected; + var prevScrollOffset = scrollOffset; + + if (key.Key == ConsoleKey.Tab) + { + if (commands.Count == 0) + { + continue; + } + barFocused = !barFocused; + needsFullRedraw = true; + continue; + } + + if (barFocused) + { + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + needsFullRedraw = true; + continue; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + needsFullRedraw = true; + continue; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + continue; + } + } + + switch (key.Key) + { + case ConsoleKey.UpArrow: + if (selected > 0) + { + selected--; + while (selected > 0 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (selected < scrollOffset) + { + scrollOffset = selected; + } + } + break; + case ConsoleKey.DownArrow: + if (selected < items.Count - 1) + { + selected++; + while (selected < items.Count - 1 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (selected >= scrollOffset + visibleCount) + { + scrollOffset = selected - visibleCount + 1; + } + } + break; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return selected; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + break; + } + + if (selected != prevSelected || scrollOffset != prevScrollOffset) + { + if (scrollOffset != prevScrollOffset) + { + // Scroll changed — need full redraw + needsFullRedraw = true; + } + else + { + // Only cursor moved within same page — redraw just the two affected lines + // Row calculation: 0-based row index of first list item + var listStartRow = 1 + 1 + (context is not null ? 1 : 0) + 1; // top border + header + context? + mid border + if (renderAboveList is not null) + { + // renderAboveList adds content before the list; we can't easily count lines + // so fall back to full redraw + needsFullRedraw = true; + } + else + { + // SetPosition uses 1-based coordinates (ANSI CUP), so add 1 + var prevRow = listStartRow + (prevSelected - scrollOffset) + 1; + var newRow = listStartRow + (selected - scrollOffset) + 1; + + // Redraw old line (remove cursor) + _console.Cursor.SetPosition(0, prevRow); + RenderPanelLineDirect($" {items[prevSelected]}"); + + // Redraw new line (add cursor) + _console.Cursor.SetPosition(0, newRow); + RenderPanelLineDirect($"[blue]>[/] {items[selected]}"); + + // Update counter if visible + if (items.Count > visibleCount) + { + var counterRow = listStartRow + visibleCount + 1; + _console.Cursor.SetPosition(0, counterRow); + RenderPanelLineDirect($"[dim]({selected + 1}/{items.Count})[/]"); + } + } + } + } + } + } + + // ── Interactive: Detail Panel ─────────────────────────────────── + + /// + /// Renders a detail view with scrollable content. + /// Use in the caller's key loop. + /// + public void RenderDetailPanel(string[] breadcrumbs, string? context, Action renderContent, string hotkeys) + { + var contentLines = CaptureContent(renderContent); + + _lastDetailBreadcrumbs = breadcrumbs; + _lastDetailContext = context; + _lastDetailHotkeys = hotkeys; + _lastDetailLines = contentLines; + _lastDetailScrollOffset = 0; + + Clear(); + _console.Cursor.Show(false); + RenderDetailFrame(breadcrumbs, context, contentLines, 0, hotkeys); + _console.Cursor.Show(true); + } + + /// + /// Handles scroll keys for the last rendered detail panel. + /// Returns true if handled, false if the caller should process the key. + /// + public bool HandleDetailScroll(ConsoleKeyInfo key) + { + if (_lastDetailLines is null || _lastDetailLines.Count == 0) + { + return false; + } + + var availableHeight = GetDetailAvailableHeight(_lastDetailContext is not null); + if (_lastDetailLines.Count <= availableHeight) + { + return false; + } + + var maxOffset = Math.Max(0, _lastDetailLines.Count - availableHeight); + var oldOffset = _lastDetailScrollOffset; + + switch (key.Key) + { + case ConsoleKey.UpArrow: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 1); + break; + case ConsoleKey.DownArrow: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 1); + break; + case ConsoleKey.PageUp: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 10); + break; + case ConsoleKey.PageDown: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 10); + break; + default: + return false; + } + + if (_lastDetailScrollOffset != oldOffset) + { + Clear(); + _console.Cursor.Show(false); + RenderDetailFrame(_lastDetailBreadcrumbs!, _lastDetailContext, _lastDetailLines, _lastDetailScrollOffset, _lastDetailHotkeys!); + _console.Cursor.Show(true); + } + + return true; + } + + // ── Interactive: Text Prompt ───────────────────────────────────── + + /// + /// Prompts for text input inside the panel frame. + /// + public string? PromptInPanel(string[] breadcrumbs, string prompt, string? currentValue = null) + { + Clear(); + _console.Cursor.Show(true); + var width = Width - 2; + + int row = 1; // 1-based row tracking + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + row++; // row 2: header + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + row++; // row 3: mid separator + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + row++; // row 4: prompt text + RenderPanelLineDirect($"[bold]{prompt}[/]"); + if (currentValue is not null) + { + row++; // current value line + RenderPanelLineDirect($"[dim]Current: {Markup.Escape(currentValue)}[/]"); + } + row++; // empty input line + var inputRow = row; + RenderEmptyLineDirect(); + row++; // mid separator + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + row++; // footer + RenderPanelLineDirect("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + + // Position cursor on the empty input line and render the "> " prompt + _console.Cursor.SetPosition(2, inputRow); + _console.Markup("[blue]>[/] "); + + var buffer = new System.Text.StringBuilder(); + while (true) + { + var key = ReadKey(); + if (key.Key == ConsoleKey.Escape) + { + return null; + } + if (key.Key == ConsoleKey.Enter) + { + var result = buffer.ToString().Trim(); + return string.IsNullOrEmpty(result) ? null : result; + } + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + _console.Markup("\b \b"); + } + continue; + } + if (key.KeyChar >= 32) + { + buffer.Append(key.KeyChar); + _console.Markup(Markup.Escape(key.KeyChar.ToString())); + } + } + } + + // ── Frame rendering ───────────────────────────────────────────── + + internal void RenderDetailFrame(string[] breadcrumbs, string? context, List contentLines, int scrollOffset, string hotkeys) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLineDirect(context); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + var availableHeight = GetDetailAvailableHeight(context is not null); + var visibleLines = contentLines.Skip(scrollOffset).Take(availableHeight).ToList(); + foreach (var line in visibleLines) + { + RenderPanelLineDirect(line); + } + + for (var i = visibleLines.Count; i < availableHeight; i++) + { + RenderEmptyLineDirect(); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + var scrollHint = contentLines.Count > availableHeight + ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} Up/Dn)[/]" + : ""; + RenderPanelLineDirect($"{hotkeys}{scrollHint}"); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + private void RenderMainMenuFrame(List commands, int barIndex) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + RenderPanelLineDirect("[bold orange1]TIGER[/]"); + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + RenderLogo(); + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, true)); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + private void RenderListFrame(string[] breadcrumbs, string? context, List items, + int selected, int scrollOffset, int visibleCount, bool barFocused, + List commands, int barIndex, Action? renderAboveList) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLineDirect(context); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + renderAboveList?.Invoke(); + + for (var i = 0; i < visibleCount; i++) + { + var idx = scrollOffset + i; + if (idx >= items.Count) + { + RenderEmptyLineDirect(); + continue; + } + + if (!barFocused && idx == selected) + { + RenderPanelLineDirect($"[blue]>[/] {items[idx]}"); + } + else + { + RenderPanelLineDirect($" {items[idx]}"); + } + } + + if (items.Count > visibleCount) + { + RenderPanelLineDirect($"[dim]({selected + 1}/{items.Count})[/]"); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, barFocused)); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + // ── Direct rendering helpers ──────────────────────────────────── + + private void RenderPanelLineDirect(string markupContent) + { + var displayContent = TruncationEnabled ? TruncateToFit(markupContent) : markupContent; + + _console.Markup($"[{BorderStyle}]{Vertical}[/] "); + _console.Markup(displayContent); + + var plainLen = Markup.Remove(displayContent).Length; + var padding = Math.Max(0, ContentWidth - plainLen); + _console.Markup(new string(' ', padding)); + _console.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); + } + + private void RenderEmptyLineDirect() + { + var width = Width - 2; + _console.Markup($"[{BorderStyle}]{Vertical}[/]"); + _console.Markup(new string(' ', width)); + _console.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); + } + + // ── Console operations ────────────────────────────────────────── + + private void Clear() + { + _console.Clear(); + } + + private ConsoleKeyInfo ReadKey() + { + var result = _console.Input.ReadKey(true); + return result ?? new ConsoleKeyInfo('\0', ConsoleKey.None, false, false, false); + } +} diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 2f9d692..d99c42b 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class TestBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly AzdoClientFactory _clientFactory; private readonly string _configDirectory; @@ -27,26 +29,30 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Test Failures[/]"); - if (_filter.IsActive) - { - AnsiConsole.MarkupLine($"Filter: {Markup.Escape(_filter.ToString())}"); - } - else - { - AnsiConsole.MarkupLine("[dim]Filter: (none)[/]"); - } - AnsiConsole.WriteLine(); - var tests = QueryTests(); + var filterText = _filter.IsActive + ? $"Filter: {Markup.Escape(_filter.ToString())}" + : "[dim]Filter: (none)[/]"; + var context = tests.Count > 0 + ? $"{filterText} [dim]({tests.Count} failed test(s))[/]" + : filterText; + if (tests.Count == 0) { - AnsiConsole.MarkupLine(_filter.IsActive + var emptyMsg = _filter.IsActive ? "[yellow]No test failures match the current filter.[/]" - : "[yellow]No test failures recorded yet.[/]"); - AnsiConsole.MarkupLine(" [blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + : "[yellow]No test failures recorded yet.[/]"; + + _ui.RenderDetailPanel( + ["Tests"], + context, + () => _ui.RenderPanelLine(emptyMsg), + PanelRenderer.BuildCommandBarString(new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + })); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) { EditFilter(); continue; } @@ -56,28 +62,28 @@ public void Browse() return; } - AnsiConsole.MarkupLine($"[dim]{tests.Count} failed test(s)[/]"); - AnsiConsole.WriteLine(); - var choices = tests.Select(t => { var title = t.TestName.Length > 70 ? t.TestName[..67] + "..." : t.TestName; - return $"[red]✗[/] {Markup.Escape(title)} [dim]({t.FailCount} build(s))[/]"; + return $"[red]X[/] {Markup.Escape(title)} [dim]({t.FailCount} build(s))[/]"; }).ToList(); - var hotkeys = _filter.IsActive - ? "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]C[/]lear [blue]H[/]elp" - : "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]H[/]elp"; + var commands = new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + new("Help", ConsoleKey.H, -3), + }; + if (_filter.IsActive) + { + commands.Add(new("Clear", ConsoleKey.C, -4)); + } - var selected = BrowserUI.SelectWithEscape("Select a test:", choices, - extraKeys: new Dictionary { - { ConsoleKey.E, -5 }, - { ConsoleKey.F, -2 }, - { ConsoleKey.H, -3 }, - { ConsoleKey.C, -4 }, - }, - useMarkup: true, - hotkeys: hotkeys); + var selected = _ui.SelectInPanel( + ["Tests"], + context, + choices, + commands); if (selected == -5) { EditFilter(); continue; } if (selected == -2) { ShowFilterMenu(); continue; } @@ -92,57 +98,133 @@ public void Browse() private void ShowTestDetail(TestRow test) { + var truncate = true; while (true) { - AnsiConsole.Clear(); - var info = BrowserUI.LoadTestDetail(_db, test.Org, test.Project, test.TestName); if (info is null) { - AnsiConsole.MarkupLine("[yellow]No test failure data found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Tests", "Detail"], + null, + () => _ui.RenderPanelLine("[yellow]No test failure data found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } - BrowserUI.RenderTestDetail(info); + _ui.TruncationEnabled = truncate; + var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; + var commands = new List + { + new("Builds with failure", ConsoleKey.B, -2), + new("Agent task", ConsoleKey.A, -3), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -5), + }; + if (info.HelixJobName is not null) + { + commands.Add(new("Helix", ConsoleKey.H, -4)); + } - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]B[/]uilds with this failure [blue]A[/]gent task [blue]Esc[/] Back"); + _ui.RenderDetailPanel( + ["Tests", Markup.Escape(shortTitle)], + null, + () => BrowserUI.RenderTestDetailInPanel(_ui, info), + PanelRenderer.BuildCommandBarString(commands)); while (true) { var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) + { + _ui.TruncationEnabled = true; return; + } + if (key.Key == ConsoleKey.T) + { + truncate = !truncate; + break; // re-render with updated truncate + } if (key.Key == ConsoleKey.B) { + _ui.TruncationEnabled = true; ShowTestBuilds(test); break; // re-render detail after returning } if (key.Key == ConsoleKey.A) { + _ui.TruncationEnabled = true; BrowserUI.CreateAgentTask(_db, info); break; // re-render detail after returning } + if (key.Key == ConsoleKey.H && info.HelixJobName is not null) + { + _ui.TruncationEnabled = true; + ShowHelixWorkItemDetail(info); + break; // re-render detail after returning + } } } } + private void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + { + _ui.RenderDetailPanel( + ["Tests", "Helix Work Item"], + null, + () => + { + if (info.IsHelixDeadletter) + { + _ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); + _ui.RenderEmptyLine(); + } + _ui.RenderField("Job", Markup.Escape(info.HelixJobName!)); + if (info.HelixWorkItemName is not null) + { + _ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); + _ui.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + } + if (info.HelixFiles is { Count: > 0 }) + { + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + _ui.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + } + else + { + _ui.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + }, + "[blue]Esc[/] Back"); + while (true) + { + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; + } + } + private void ShowTestBuilds(TestRow test) { - AnsiConsole.Clear(); var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; - AnsiConsole.MarkupLine("[bold underline]Builds with failure[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(shortTitle)}[/]"); - AnsiConsole.WriteLine(); - var builds = QueryTestBuilds(test); if (builds.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No builds found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + _ui.RenderDetailPanel( + ["Tests", Markup.Escape(shortTitle), "Builds"], + null, + () => _ui.RenderPanelLine("[yellow]No builds found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } @@ -151,7 +233,12 @@ private void ShowTestBuilds(TestRow test) BrowserUI.FormatBuildChoice(b.BuildId, b.DefinitionName, b.Result, b.FinishTime, b.PrNumber)).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, useMarkup: true); + var commands = new List(); + var selected = _ui.SelectInPanel( + ["Tests", Markup.Escape(shortTitle), "Builds"], + $"[dim]{builds.Count} build(s) with this failure[/]", + choices, + commands); if (selected >= 0) { @@ -259,85 +346,96 @@ LIMIT 30 private void EditFilter() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Edit Filter[/]"); - AnsiConsole.MarkupLine("[dim]Syntax: test:VALUE repo:VALUE def:VALUE pr:NUMBER[/]"); - AnsiConsole.MarkupLine("[dim]Examples: test:Serialization repo:roslyn def:*-CI[/]"); - AnsiConsole.MarkupLine("[dim]Append ! for exact match. Press Esc to cancel[/]"); - AnsiConsole.WriteLine(); - if (_filter.IsActive) - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.Markup("[blue]> [/]"); - - var buffer = new System.Text.StringBuilder(); - while (true) + var currentValue = _filter.IsActive ? _filter.ToString() : null; + var result = _ui.PromptInPanel( + ["Tests", "Edit Filter"], + "Enter filter expression (e.g. test:Serialization repo:roslyn def:*-CI)", + currentValue); + + if (result is not null) { - var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.Escape) return; - if (key.Key == ConsoleKey.Enter) - { - AnsiConsole.WriteLine(); - var input = buffer.ToString().Trim(); - if (!string.IsNullOrEmpty(input)) - _filter.ParseExpression(input); - SaveFilter(); - return; - } - if (key.Key == ConsoleKey.Backspace) - { - if (buffer.Length > 0) { buffer.Remove(buffer.Length - 1, 1); Console.Write("\b \b"); } - continue; - } - if (key.KeyChar >= 32) { buffer.Append(key.KeyChar); Console.Write(key.KeyChar); } + _filter.ParseExpression(result); + SaveFilter(); } } private void ShowFilterMenu() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Set Filter[/]"); - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]N[/]ame filter"); - AnsiConsole.MarkupLine(" [blue]R[/]epository"); - AnsiConsole.MarkupLine(" [blue]D[/]efinition"); - AnsiConsole.MarkupLine(" [blue]K[/]ind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]P[/]R number"); - AnsiConsole.MarkupLine(" [blue]C[/]lear all filters"); - AnsiConsole.MarkupLine(" [blue]Esc[/] Cancel"); - - var key = Console.ReadKey(true); - switch (key.Key) + while (true) { - case ConsoleKey.N: - _filter.TestNamePattern = BrowserUI.PromptPattern("Test name pattern (e.g. Serialization, *EditAndContinue*):"); - break; - case ConsoleKey.R: - _filter.RepoPattern = BrowserUI.PromptPattern("Repository pattern (e.g. roslyn, dotnet/*):"); - break; - case ConsoleKey.D: - _filter.DefinitionPattern = BrowserUI.PromptPattern("Definition pattern (e.g. ci, roslyn-CI*):"); - break; - case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); - break; - case ConsoleKey.P: - _filter.PrNumber = PromptPrNumber(); - break; - case ConsoleKey.C: - _filter.Clear(); - break; + var commands = new List + { + new("Name", ConsoleKey.N, 1), + new("Repository", ConsoleKey.R, 2), + new("Definition", ConsoleKey.D, 3), + new("Kind", ConsoleKey.K, 4), + new("PR number", ConsoleKey.P, 5), + new("Clear", ConsoleKey.C, 6), + }; + + _ui.RenderDetailPanel( + ["Tests", "Filter"], + null, + () => + { + _ui.RenderPanelLine("Filter test failures by name, repository, definition, kind, etc."); + _ui.RenderEmptyLine(); + + if (_filter.IsActive) + { + _ui.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + } + else + { + _ui.RenderPanelLine("[dim]No filter active[/]"); + } + + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + }, + PanelRenderer.BuildCommandBarString(commands)); + + var key = Console.ReadKey(true); + switch (key.Key) + { + case ConsoleKey.N: + _filter.TestNamePattern = _ui.PromptInPanel(["Tests", "Filter"], "Test name pattern (e.g. Serialization, *EditAndContinue*)"); + SaveFilter(); + continue; + case ConsoleKey.R: + _filter.RepoPattern = _ui.PromptInPanel(["Tests", "Filter"], "Repository pattern (e.g. roslyn, dotnet/*)"); + SaveFilter(); + continue; + case ConsoleKey.D: + _filter.DefinitionPattern = _ui.PromptInPanel(["Tests", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); + SaveFilter(); + continue; + case ConsoleKey.K: + _filter.KindPattern = BrowserUI.PromptKindFilter(_ui); + SaveFilter(); + continue; + case ConsoleKey.P: + _filter.PrNumber = PromptPrNumber(); + SaveFilter(); + continue; + case ConsoleKey.C: + _filter.Clear(); + SaveFilter(); + continue; + case ConsoleKey.Escape: + return; + default: + continue; + } } - SaveFilter(); } /// /// Prompts the user to enter a PR number. Returns null if cancelled or invalid. /// - private static int? PromptPrNumber() + private int? PromptPrNumber() { - AnsiConsole.WriteLine(); - var raw = BrowserUI.PromptPattern("PR number (e.g. 12345):"); + var raw = _ui.PromptInPanel(["Tests", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -345,31 +443,33 @@ private void ShowFilterMenu() return int.TryParse(raw, out var pr) ? pr : null; } - private static void ShowFilterHelp() + private void ShowFilterHelp() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Test Filter Help[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Quick filter (E):[/]"); - AnsiConsole.MarkupLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Matching (default: contains / LIKE):[/]"); - AnsiConsole.MarkupLine(" [dim]Serial → matches tests containing 'Serial'[/]"); - AnsiConsole.MarkupLine(" [dim]*EditAndContinue* → matches tests with 'EditAndContinue'[/]"); - AnsiConsole.MarkupLine(" [dim]roslyn → matches repos containing 'roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Exact match (append !):[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Filter prefixes:[/]"); - AnsiConsole.MarkupLine(" [blue]test:[/] Test name"); - AnsiConsole.MarkupLine(" [blue]repo:[/] Repository name"); - AnsiConsole.MarkupLine(" [blue]def:[/] Definition/pipeline name"); - AnsiConsole.MarkupLine(" [blue]kind:[/] Build kind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]pr:[/] PR number (e.g. 12345)"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Multiple filters combine with AND.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to continue...[/]"); + _ui.RenderDetailPanel( + ["Tests", "Filter Help"], + null, + () => + { + _ui.RenderPanelLine("[bold]Quick filter (E):[/]"); + _ui.RenderPanelLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + _ui.RenderPanelLine(" [dim]Serial - matches tests containing 'Serial'[/]"); + _ui.RenderPanelLine(" [dim]*EditAndContinue* - matches tests with 'EditAndContinue'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Exact match (append !):[/]"); + _ui.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Filter prefixes:[/]"); + _ui.RenderPanelLine(" [blue]test:[/] Test name"); + _ui.RenderPanelLine(" [blue]repo:[/] Repository name"); + _ui.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + _ui.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + _ui.RenderPanelLine(" [blue]pr:[/] PR number"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + }, + "[blue]Esc[/] Back"); Console.ReadKey(true); } @@ -462,3 +562,5 @@ public void Save(string configDirectory) } } } + + diff --git a/src/Tiger/TigerDatabase.cs b/src/Tiger/TigerDatabase.cs index db1768e..58564b6 100644 --- a/src/Tiger/TigerDatabase.cs +++ b/src/Tiger/TigerDatabase.cs @@ -704,8 +704,9 @@ FROM build_analyses ba public void Dispose() { - // Connection pooling: no shared connection to dispose. - // Microsoft.Data.Sqlite manages the pool internally. + // Clear the connection pool so pooled connections release the file lock. + // This is critical for tests that delete the DB file after use. + SqliteConnection.ClearPool(new SqliteConnection(ConnectionString)); } }