Overhaul home screen UI: icons, hamburger sidebar, layout cleanup, mouse zones

Phase 1 — Layout: remove paths from title bar (moved to Config tab); fold
Scan Results panel into Fleet Summary with live compliance progress; fix
panel misalignment by removing free-floating status bar; simplify Server
Detail to name/drift/error only; redesign Recent Runs to 1-line-per-run
RAG-colored rows sorted by descending run number; add full command line to
Run Details screen.

Phase 2 — Mouse (bubblezone v1.0.0): replace coordinate-math hit-testing
with zone.Mark()/zone.Get().InBounds() for server rows, run rows, sidebar
tabs, and hamburger toggle. Double-click on a run opens details.

Phase 3 — Icons & sidebar: add iconSet struct with Icons singleton and
Get(name) dynamic lookup; Nerd Font glyphs for all actions; collapsed
sidebar shows tab icons only, expanded shows icon+label; m key or mouse
click on hamburger toggles; footer hints updated with icons.

Phase 4 — Config tab: show PlaybookDir and InventoryPath at top.

Tests: 21 UI tests covering truncate edge cases, fleet counts, group
sorting, icon dispatch, drift icons, sidebar width, menu toggle key,
fleet summary compliance display, title bar paths removal, server detail
field removal, recent runs format, seq numbering, and style uniqueness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:55:33 -04:00
parent a4c8fb91f6
commit 26f39fad0b
8 changed files with 806 additions and 355 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lrstanley/bubblezone v1.0.0
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -30,5 +31,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/text v0.24.0 // indirect
)
+4
View File
@@ -24,6 +24,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -50,6 +52,8 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+69 -55
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
@@ -81,15 +82,16 @@ type App struct {
screen Screen
// --- Home screen state ---
sidebarTab int
serverCursor int
runCursor int
activePanel int
filterInput textinput.Model
filtering bool
filterStr string
runs []*history.RunRecord
hostFindings map[string][]ansible.Finding
sidebarTab int
sidebarExpanded bool // hamburger: true = icon+label, false = icon-only
serverCursor int
runCursor int
activePanel int
filterInput textinput.Model
filtering bool
filterStr string
runs []*history.RunRecord
hostFindings map[string][]ansible.Finding
// --- Add/Edit server form ---
// formFields order: name, group, ansible_host, ansible_user, ansible_port
@@ -148,6 +150,10 @@ type App struct {
configSyncing bool
}
func init() {
zone.NewGlobal()
}
// New constructs the initial App model.
func New(cfg *config.Config, inv *inventory.Inventory, hist *history.History) *App {
placeholders := [5]string{"media01", "media", "192.168.1.10", "frank", "22"}
@@ -356,6 +362,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a *App) View() string {
return zone.Scan(a.view())
}
func (a *App) view() string {
if a.width == 0 {
return "Loading…"
}
@@ -843,6 +853,14 @@ func (a *App) selectedRunRecord() *history.RunRecord {
return a.runs[a.runCursor]
}
// sidebarWidth returns the current sidebar column width based on expand state.
func (a *App) sidebarWidth() int {
if a.sidebarExpanded {
return 14
}
return 4
}
func (a *App) loadPlaybooks() {
pbs, _ := playbooks.Discover(a.cfg.EffectivePlaybookDir())
a.playbookList = pbs
@@ -864,7 +882,6 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case ScreenHome:
return a.handleHomeMouseMsg(msg)
case ScreenRunDetails:
// viewport handles its own scrolling
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
return a, cmd
@@ -879,22 +896,20 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
// homeLayout returns approximate screen coordinates for the home layout.
// These are estimates; they are intentionally simple so row selection works
// at typical terminal sizes without precise measurement.
// These are estimates kept for wheel-scroll fallback; primary click detection
// uses zone.Get().InBounds() instead of coordinate math.
func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, rightX0, runsRowY0 int) {
// title bar: y=0
// servers panel: top border(y=1), header(y=2), blank(y=3), colhdr(y=4), divider(y=5), data(y=6+)
const titleLines = 1
const panelPreamble = 5 // border + "Servers" + MarginBottom-blank + col-header + divider
const panelPreamble = 5
serverRowY0 = titleLines + panelPreamble
sideW := 6
sideW := a.sidebarWidth()
mainW := a.width - sideW - 1
serversW := mainW * 64 / 100
serversW := mainW * 66 / 100
serverX0 = sideW + 2 // sidebar + left border + left padding
serverX0 = sideW + 2
serverX1 = sideW + serversW
rightX0 = serverX1 + 2 // spacer(1) + left border of right panel(1)
rightX0 = serverX1 + 2
// Runs panel sits below the detail panel (detailH=12) in the right column.
// runs panel preamble: border(1) + "Recent Runs"(1) + MarginBottom-blank(1) = 3
@@ -905,60 +920,59 @@ func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, rightX0, runsRowY0
}
func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
serverRowY0, serverX0, serverX1, rightX0, runsRowY0 := a.homeLayout()
const sideW = 6
switch msg.Button {
case tea.MouseButtonWheelUp:
a.moveCursorUp()
case tea.MouseButtonWheelDown:
a.moveCursorDown()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
break
}
x, y := msg.X, msg.Y
// Sidebar tab clicks (SERV / JOBS / CFG).
// renderSidebar emits: empty, tab0, empty, tab1, empty, tab2, empty
// starting at body y=0 (abs y=1, since title bar is y=0).
if x < sideW {
bodyY := y - 1
switch {
case bodyY >= 1 && bodyY <= 2:
a.sidebarTab = TabServers
case bodyY >= 3 && bodyY <= 4:
a.sidebarTab = TabJobs
case bodyY >= 5 && bodyY <= 6:
if a.sidebarTab != TabConfig {
// Hamburger toggle.
if zone.Get("hamburger").InBounds(msg) {
a.sidebarExpanded = !a.sidebarExpanded
return a, nil
}
// Sidebar tab clicks.
for i, id := range []string{"tab-0", "tab-1", "tab-2"} {
if zone.Get(id).InBounds(msg) {
if i == TabConfig && a.sidebarTab != TabConfig {
a.sidebarTab = TabConfig
a.syncActivePanelToTab()
return a, a.configTabEnterCmd()
}
a.sidebarTab = TabConfig
a.sidebarTab = i
a.syncActivePanelToTab()
return a, nil
}
a.syncActivePanelToTab()
return a, nil
}
if x >= serverX0 && x < serverX1 {
// Clicked in the servers panel
a.activePanel = PanelServers
row := y - serverRowY0
hosts := a.filteredServers()
if row >= 0 && row < len(hosts) {
a.serverCursor = row
// Server row clicks.
hosts := a.filteredServers()
for i := range hosts {
if zone.Get(fmt.Sprintf("server-%d", i)).InBounds(msg) {
a.serverCursor = i
a.activePanel = PanelServers
return a, nil
}
} else if x >= rightX0 && y >= runsRowY0 {
// Clicked in the recent runs panel (below server detail in right column)
a.activePanel = PanelRuns
// Each run entry is 3 lines: line1, line2, blank
row := (y - runsRowY0) / 3
if row >= 0 && row < len(a.runs) {
a.runCursor = row
}
// Run row clicks — single click selects, double-click opens details.
for i := range a.runs {
if zone.Get(fmt.Sprintf("run-%d", i)).InBounds(msg) {
if a.runCursor == i && a.activePanel == PanelRuns {
// Second click on already-selected run → open details.
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
return a, nil
}
a.runCursor = i
a.activePanel = PanelRuns
return a, nil
}
}
}
+182 -246
View File
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
@@ -142,6 +143,10 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
return a, nil
case key.Matches(km, keys.MenuToggle):
a.sidebarExpanded = !a.sidebarExpanded
return a, nil
}
return a, nil
@@ -271,21 +276,12 @@ func (a *App) viewHome() string {
func (a *App) renderTitleBar(w int) string {
title := titleStyle.Render("ansibleTUI")
inv := pathStyle.Render("inv: " + shorten(a.cfg.EffectiveInventoryPath()))
pbs := pathStyle.Render("pb: " + shorten(a.cfg.EffectivePlaybookDir()))
right := inv + " " + pbs
gap := w - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
content := title + strings.Repeat(" ", gap) + right
border := dividerStyle.Render(strings.Repeat("─", w))
return content + "\n" + border
return title + "\n" + border
}
func (a *App) renderBody(w, bodyH int) string {
sideW := 6
sideW := a.sidebarWidth()
mainW := w - sideW - 1
if mainW < 10 {
mainW = 10
@@ -298,16 +294,44 @@ func (a *App) renderBody(w, bodyH int) string {
}
func (a *App) renderSidebar(w, h int) string {
tabs := []string{"SERV", "JOBS", "CFG"}
var lines []string
for i, t := range tabs {
if i == a.sidebarTab {
lines = append(lines, navActiveStyle.Width(w).Render(t))
} else {
lines = append(lines, navStyle.Width(w).Render(t))
}
lines = append(lines, "")
// Hamburger toggle row.
hamburgerIcon := navStyle.Width(w).Render(Icons.Hamburger)
hamburgerRow := zone.Mark("hamburger", hamburgerIcon)
type tabDef struct {
icon string
label string
id int
}
tabs := []tabDef{
{Icons.Servers, "Servers", TabServers},
{Icons.Jobs, "Jobs", TabJobs},
{Icons.Config, "Config", TabConfig},
}
var lines []string
lines = append(lines, hamburgerRow, "")
for _, t := range tabs {
var rendered string
if a.sidebarExpanded {
text := t.icon + " " + t.label
if t.id == a.sidebarTab {
rendered = navActiveStyle.Width(w).Render(text)
} else {
rendered = navStyle.Width(w).Render(text)
}
} else {
if t.id == a.sidebarTab {
rendered = navActiveStyle.Width(w).Render(t.icon)
} else {
rendered = navStyle.Width(w).Render(t.icon)
}
}
zoneID := fmt.Sprintf("tab-%d", t.id)
lines = append(lines, zone.Mark(zoneID, rendered), "")
}
content := strings.Join(lines, "\n")
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, content)
}
@@ -324,18 +348,12 @@ func (a *App) renderMainContent(w, bodyH int) string {
}
func (a *App) renderServersTab(w, bodyH int) string {
statusBar := a.renderStatusBar(w)
statusH := lipgloss.Height(statusBar)
panelH := bodyH - statusH
if panelH < 6 {
panelH = 6
}
topH := 8
if panelH < 18 {
// Fixed top section height; no variable-height status bar so columns stay aligned.
topH := 9
if bodyH < 20 {
topH = 6
}
contentH := panelH - topH
contentH := bodyH - topH
if contentH < 8 {
contentH = 8
}
@@ -346,138 +364,90 @@ func (a *App) renderServersTab(w, bodyH int) string {
}
rightW := w - serversW - 1
detailH := contentH / 2
if detailH < 8 {
detailH = 8
// Server detail is compact now (fewer fields); give it a fixed small height.
detailH := 7
if contentH < 16 {
detailH = 5
}
runsH := contentH - detailH
if runsH < 4 {
runsH = 4
}
top := lipgloss.JoinHorizontal(lipgloss.Top,
a.renderFleetSummaryPanel(serversW, topH),
" ",
a.renderQuickActionsPanel(rightW, topH),
)
// Fleet summary spans full width so compliance results have room.
fleet := a.renderFleetSummaryPanel(w, topH)
servers := a.renderServersPanel(serversW, contentH)
detail := a.renderServerDetail(rightW, detailH)
runs := a.renderRecentRunsPanel(rightW, runsH)
right := lipgloss.JoinVertical(lipgloss.Left, detail, runs)
panels := lipgloss.JoinHorizontal(lipgloss.Top, servers, " ", right)
return lipgloss.JoinVertical(lipgloss.Left, top, panels, statusBar)
return lipgloss.JoinVertical(lipgloss.Left, fleet, panels)
}
func (a *App) renderFleetSummaryPanel(w, h int) string {
total, clean, drift, failed, _ := fleetCounts(a.inv.Hosts)
checks := len(compliance.Plan(a.cfg.Compliance, a.inv))
runningLine := ""
if a.complianceRunning {
runningLine = " " + runChangedStyle.Render(" "+a.complianceAction)
}
badges := badgeHostsStyle.Render(fmt.Sprintf("%d hosts", total)) + " " +
badgeCleanStyle.Render(Icons.Clean+" "+fmt.Sprintf("%d clean", clean)) + " " +
badgeDriftStyle.Render(Icons.Drift+" "+fmt.Sprintf("%d drift", drift)) + " " +
badgeFailedStyle.Render(Icons.Failed+" "+fmt.Sprintf("%d failed", failed))
lines := []string{
boldStyle.Render("Fleet Summary"),
"",
badgeHostsStyle.Render(fmt.Sprintf("%d hosts", total)) + " " +
badgeCleanStyle.Render(fmt.Sprintf("%d clean", clean)) + " " +
badgeDriftStyle.Render(fmt.Sprintf("%d drift", drift)) + " " +
badgeFailedStyle.Render(fmt.Sprintf("%d failed", failed)) + runningLine,
subtleStyle.Render(fmt.Sprintf("%d mapped compliance checks", checks)),
}
return panelStyle.Width(max(1, w-4)).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderQuickActionsPanel(w, h int) string {
// When a compliance scan has results, show a per-job breakdown.
// When idle, show keyboard hints.
if len(a.complianceJobResults) > 0 {
return a.renderScanResultsPanel(w, h)
badges,
}
hint := func(k, d string) string {
return hintKeyStyle.Render(k) + " " + hintDescStyle.Render(d)
// Compliance progress / results inline (replaces standalone Scan Results panel).
if a.complianceRunning {
progressLine := statusScanning.Render(Icons.Running+" "+a.statusMsg)
lines = append(lines, "", progressLine)
} else if len(a.complianceJobResults) > 0 {
lines = append(lines, "")
// How many result rows fit inside the panel height.
maxRows := h - 6 // header(1) + blank(1) + badges(1) + blank(1) + border(2)
if maxRows < 1 {
maxRows = 1
}
innerW := w - 6 // panel border+padding
if innerW < 10 {
innerW = 10
}
for i, r := range a.complianceJobResults {
if i >= maxRows {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more — see JOBS tab", len(a.complianceJobResults)-i)))
break
}
pb := strings.TrimPrefix(r.playbook, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
if r.label != "all" && r.label != "" {
pb = pb + "/" + r.label
}
pb = padRight(pb, innerW-14)
var statusStr string
switch r.status {
case "clean":
statusStr = runCleanStyle.Render(Icons.Clean + " clean")
case "drift":
statusStr = runChangedStyle.Render(Icons.Drift + " drift")
default:
statusStr = runFailedStyle.Render(Icons.Failed + " " + r.status)
}
lines = append(lines, " "+pb+statusStr)
}
} else if a.errMsg != "" {
lines = append(lines, "", formErrorStyle.Render(Icons.Failed+" "+a.errMsg))
} else if a.statusMsg != "" {
lines = append(lines, "", dimStyle.Render(a.statusMsg))
}
// Keep each hint line short enough to avoid wrapping in the narrow right panel.
// Visible chars per line stay under 20 to fit on 80-col terminals.
lines := []string{
boldStyle.Render("Quick Actions"),
"",
hint("c", "scan") + " " + hint("f", "fix"),
hint("r", "apply") + " " + hint("Enter", "flow"),
}
return panelStyle.Width(max(1, w-4)).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderScanResultsPanel(w, h int) string {
innerW := max(1, w-4)
// Status icon + colour for each outcome.
statusStr := func(status string) string {
switch status {
case "clean":
return runCleanStyle.Render("✓ clean")
case "drift":
return runChangedStyle.Render("~ drift")
case "failed", "unreachable":
return runFailedStyle.Render("✗ " + status)
default:
return dimStyle.Render(status)
}
}
running := a.complianceRunning
var header string
if running {
header = boldStyle.Render("Scan Results") + " " + dimStyle.Render("running…")
} else {
header = boldStyle.Render("Scan Results")
}
lines := []string{header, ""}
// How many result rows fit: inner height minus header(1) + blank(1).
maxRows := (h - 2) - 2
if maxRows < 1 {
maxRows = 1
}
results := a.complianceJobResults
shown := results
overflow := 0
if len(results) > maxRows {
shown = results[:maxRows-1]
overflow = len(results) - (maxRows - 1)
}
pbW := innerW - 12 // leave room for status column
if pbW < 6 {
pbW = 6
}
for _, r := range shown {
// Compact playbook display: trim leading "playbooks/" and extension.
pb := r.playbook
pb = strings.TrimPrefix(pb, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
if r.label != "all" && r.label != "" {
pb = pb + "/" + r.label
}
pb = padRight(pb, pbW)
lines = append(lines, pb+" "+statusStr(r.status))
}
if overflow > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more (see JOBS tab)", overflow)))
} else if len(results) == 0 && running {
lines = append(lines, dimStyle.Render("waiting for jobs…"))
}
return panelStyle.Width(innerW).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderServerDetail(w, h int) string {
header := panelHeaderStyle.Render("Server Detail")
innerW := w - 4
@@ -491,27 +461,18 @@ func (a *App) renderServerDetail(w, h int) string {
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
label := lipgloss.NewStyle().Foreground(colorSubtle).Width(9).Render
nameStr := driftIcon(host.DriftState) + " " + driftNameStyle(host.DriftState).Render(host.Name)
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorCyan).Render(host.Name))
if host.AnsibleHost != "" {
lines = append(lines, label("IP")+" "+dimStyle.Render(host.AnsibleHost))
}
if host.Group != "" {
lines = append(lines, label("Group")+" "+subtleStyle.Render(host.Group))
}
lines = append(lines, label("Status")+" "+renderReachable(host.Reachable))
lines = append(lines, label("Drift")+" "+renderDrift(host.DriftState))
if host.LastCheck != "" {
lines = append(lines, label("Checked")+" "+dimStyle.Render(host.LastCheck))
}
if host.LastApply != "" {
lines = append(lines, label("Applied")+" "+dimStyle.Render(host.LastApply))
lines = append(lines, nameStr)
driftCount := countHostDrift(a.hostFindings[host.Name])
if driftCount > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf("%d drift item(s)", driftCount)))
}
if host.LastError != "" {
// Wrap the error message to the available width.
maxErrW := innerW - 11 // label width (9) + space (1) + margin
maxErrW := innerW - 2
if maxErrW < 10 {
maxErrW = 10
}
@@ -519,26 +480,11 @@ func (a *App) renderServerDetail(w, h int) string {
if len(errText) > maxErrW {
errText = errText[:maxErrW-1] + "…"
}
lines = append(lines, label("Error")+" "+formErrorStyle.Render(errText))
lines = append(lines, formErrorStyle.Render(errText))
}
if a.cfg.RecentPlaybook != "" {
argv := ansible.BuildPlaybookArgs(
a.cfg.EffectiveInventoryPath(),
a.cfg.RecentPlaybook,
host.Name,
ansible.ModeCheckDiff,
)
cmdStr := strings.Join(argv, " ")
maxCmd := innerW - 4
if maxCmd < 4 {
maxCmd = 4
}
if len(cmdStr) > maxCmd {
cmdStr = cmdStr[:maxCmd] + "…"
}
lines = append(lines, "")
lines = append(lines, dimStyle.Render("$ "+cmdStr))
if findings := a.hostFindings[host.Name]; len(findings) > 0 {
lines = append(lines, dimStyle.Render("Enter for details"))
}
content := header + "\n" + strings.Join(lines, "\n")
@@ -600,6 +546,7 @@ func (a *App) renderServersPanel(w, h int) string {
} else {
line = nameIP + stateStr + driftStr + lastCheck
}
line = zone.Mark(fmt.Sprintf("server-%d", hostIdx), line)
rows = append(rows, row{text: line, hostIndex: hostIdx})
hostIdx++
}
@@ -627,15 +574,15 @@ func (a *App) renderServersPanel(w, h int) string {
if innerW < 1 {
innerW = 1
}
return panelStyle.Width(innerW).Height(h - 2).Render(content)
// No Height() — zone markers cause lipgloss to miscalculate height and clip rows.
return panelStyle.Width(innerW).Render(content)
}
func (a *App) renderRecentRunsPanel(w, h int) string {
const linesPerRun = 3
header := panelHeaderStyle.Render("Recent Runs")
// Fit runs inside panel: border(2) + header block(~2) + padding
maxRuns := (h - 6) / linesPerRun
// 1 line per run; border(2) + header block(~2) + padding(2)
maxRuns := h - 6
if maxRuns < 1 {
maxRuns = 1
}
@@ -652,6 +599,11 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
start = a.runCursor - maxRuns + 1
}
innerW := w - 4
if innerW < 1 {
innerW = 1
}
var lines []string
if start > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↑ %d more", start)))
@@ -664,37 +616,47 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
for i := start; i < end; i++ {
run := a.runs[i]
timeStr := runTimeStyle.Render(padRight(run.TimeLabel(), 5))
pbStr := runPlaybookStyle.Render(run.Playbook)
line1 := timeStr + " " + pbStr
seqNum := len(a.runs) - i // newest run = highest number
hostMode := subtleStyle.Render(padRight(run.Host+" / "+run.Mode, w-10))
statusStr := run.StatusSummary()
var statusStyled string
pb := strings.TrimPrefix(run.Playbook, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
line := fmt.Sprintf("%3d %-20s %-10s %-10s",
seqNum,
truncate(pb, 20),
truncate(run.Host, 10),
truncate(run.Mode, 10),
)
// Color the whole line by status — no separate icon needed.
var colorStyle lipgloss.Style
switch run.Status {
case "drift":
statusStyled = runChangedStyle.Render(statusStr)
case "clean", "ok":
statusStyled = runCleanStyle.Render(statusStr)
colorStyle = runLineCleanStyle
case "drift":
colorStyle = runLineDriftStyle
case "failed":
statusStyled = runFailedStyle.Render(statusStr)
colorStyle = runLineFailedStyle
default:
statusStyled = runUnreachStyle.Render(statusStr)
colorStyle = runLineUnreachStyle
}
line2 := hostMode + statusStyled
if i == a.runCursor && a.activePanel == PanelRuns {
line1 = runSelectedBg.Width(w - 6).Render(line1)
line2 = runSelectedBg.Width(w - 6).Render(line2)
line = runSelectedBg.Width(innerW).Render(colorStyle.Render(line))
} else {
line = colorStyle.Render(line)
}
lines = append(lines, line1, line2, "")
line = zone.Mark(fmt.Sprintf("run-%d", i), line)
lines = append(lines, line)
}
if len(a.runs) == 0 {
lines = append(lines, subtleStyle.Render("No runs yet."))
}
// Hard-cap content lines so the panel cannot grow past the layout height.
// Hard-cap lines to panel height.
maxContentLines := h - 2
if maxContentLines < 3 {
maxContentLines = 3
@@ -706,64 +668,26 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
allLines = allLines[:maxContentLines]
}
innerW := w - 4
if innerW < 1 {
innerW = 1
}
return panelStyle.Width(innerW).Height(h - 2).Render(strings.Join(allLines, "\n"))
}
func (a *App) renderStatusBar(w int) string {
if a.errMsg != "" {
return statusBarStyle.Width(w).Render(formErrorStyle.Render("✗ " + a.errMsg))
}
if a.statusMsg != "" {
return statusBarStyle.Width(w).Render(subtleStyle.Render(a.statusMsg))
}
h := a.selectedServer()
if h == nil {
return statusBarStyle.Width(w).Render("")
}
name := statusHostStyle.Render(h.Name)
ip := ""
if h.AnsibleHost != "" {
ip = " " + dimStyle.Render("("+h.AnsibleHost+")")
}
group := ""
if h.Group != "" {
group = " " + subtleStyle.Render(h.Group)
}
drift := ""
switch h.DriftState {
case "drift":
drift = " " + statusDrift.Render("drift detected")
case "clean":
drift = " " + statusClean.Render("clean")
case "failed", "unreachable":
drift = " " + statusFailed.Render(h.DriftState)
case "scanning":
drift = " " + statusScanning.Render("scanning…")
case "fixing":
drift = " " + statusFixing.Render("fixing…")
}
msg := statusBarStyle.Render("Selected: ") + name + ip + group + drift
return lipgloss.NewStyle().Width(w).Render(msg)
// No Height() constraint here — zone markers inside the content cause lipgloss
// to miscalculate line height and clip runs. Height is controlled by allLines above.
return panelStyle.Width(innerW).Render(strings.Join(allLines, "\n"))
}
func (a *App) renderFooter(w int) string {
hint := func(k, icon, desc string) string {
return hintKeyStyle.Render(k) + " " + hintDescStyle.Render(icon+" "+desc)
}
hints := []string{
hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select"),
hintKeyStyle.Render("/") + " " + hintDescStyle.Render("filter"),
hintKeyStyle.Render("a") + " " + hintDescStyle.Render("add"),
hintKeyStyle.Render("e") + " " + hintDescStyle.Render("edit"),
hintKeyStyle.Render("p") + " " + hintDescStyle.Render("ping"),
hintKeyStyle.Render("c") + " " + hintDescStyle.Render("scan"),
hintKeyStyle.Render("f") + " " + hintDescStyle.Render("fix"),
hintKeyStyle.Render("r") + " " + hintDescStyle.Render("apply"),
hint("a", Icons.Add, "add"),
hint("e", Icons.Edit, "edit"),
hint("p", Icons.Ping, "ping"),
hint("c", Icons.Scan, "scan"),
hint("f", Icons.Fix, "fix"),
hint("r", Icons.Play, "apply"),
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("details"),
hintKeyStyle.Render("m") + " " + hintDescStyle.Render(Icons.Hamburger+" menu"),
hintKeyStyle.Render("q") + " " + hintDescStyle.Render("quit"),
}
bar := strings.Join(hints, " ")
@@ -874,11 +798,11 @@ func (a *App) renderConfigTab(w, bodyH int) string {
cfgPath, _ := config.ConfigPath()
lines := []string{
subtleStyle.Render("Config file: ") + cfgPath,
subtleStyle.Render("Playbooks: ") + a.cfg.EffectivePlaybookDir(),
subtleStyle.Render("Inventory: ") + a.cfg.EffectiveInventoryPath(),
subtleStyle.Render("Runtime: ") + a.cfg.Runtime,
subtleStyle.Render("Recent playbook:") + a.cfg.RecentPlaybook,
boldStyle.Render("Paths"),
subtleStyle.Render(" Config: ") + dimStyle.Render(cfgPath),
subtleStyle.Render(" Playbooks: ") + dimStyle.Render(a.cfg.EffectivePlaybookDir()),
subtleStyle.Render(" Inventory: ") + dimStyle.Render(a.cfg.EffectiveInventoryPath()),
subtleStyle.Render(" Runtime: ") + dimStyle.Render(a.cfg.Runtime),
"",
panelHeaderStyle.Render("Git repositories"),
}
@@ -944,6 +868,18 @@ func (a *App) syncActivePanelToTab() {
}
}
// truncate clips a string to maxLen runes, appending "…" if clipped.
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
return string(runes[:maxLen-1]) + "…"
}
// shorten abbreviates a path for display — ~-relative and capped at 3 tail components.
func shorten(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
+380
View File
@@ -0,0 +1,380 @@
package ui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
// ---- truncate ----
func TestTruncateShortString(t *testing.T) {
if got := truncate("hello", 10); got != "hello" {
t.Fatalf("got %q, want %q", got, "hello")
}
}
func TestTruncateExactLength(t *testing.T) {
if got := truncate("hello", 5); got != "hello" {
t.Fatalf("got %q, want %q", got, "hello")
}
}
func TestTruncateOverLength(t *testing.T) {
got := truncate("hello world", 6)
if !strings.HasSuffix(got, "…") {
t.Fatalf("truncated string should end with ellipsis, got %q", got)
}
if lipgloss.Width(got) > 6 {
t.Fatalf("truncated string width %d exceeds max 6", lipgloss.Width(got))
}
}
func TestTruncateUnicode(t *testing.T) {
// "héllo" is 5 runes; max 4 should give 3 runes + ellipsis.
got := truncate("héllo", 4)
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis, got %q", got)
}
if len([]rune(got)) != 4 {
t.Fatalf("rune count = %d, want 4", len([]rune(got)))
}
}
func TestTruncateMinLength(t *testing.T) {
got := truncate("abc", 1)
if got != "…" {
t.Fatalf("got %q, want %q", got, "…")
}
}
// ---- fleetCounts ----
func TestFleetCountsEmpty(t *testing.T) {
total, clean, drift, failed, unknown := fleetCounts(nil)
if total != 0 || clean != 0 || drift != 0 || failed != 0 || unknown != 0 {
t.Fatalf("expected all zeros for empty slice")
}
}
func TestFleetCountsMixed(t *testing.T) {
hosts := []*inventory.Host{
{DriftState: "clean"},
{DriftState: "clean"},
{DriftState: "drift"},
{DriftState: "failed"},
{DriftState: "unreachable"},
{DriftState: ""},
}
total, clean, drift, failed, unknown := fleetCounts(hosts)
if total != 6 {
t.Fatalf("total = %d, want 6", total)
}
if clean != 2 {
t.Fatalf("clean = %d, want 2", clean)
}
if drift != 1 {
t.Fatalf("drift = %d, want 1", drift)
}
if failed != 2 { // "failed" and "unreachable" both increment failed
t.Fatalf("failed = %d, want 2", failed)
}
if unknown != 1 {
t.Fatalf("unknown = %d, want 1", unknown)
}
}
// ---- groupHosts ----
func TestGroupHostsSortedByGroup(t *testing.T) {
hosts := []*inventory.Host{
{Name: "z-host", Group: "beta"},
{Name: "a-host", Group: "beta"},
{Name: "m-host", Group: "alpha"},
}
groups := groupHosts(hosts)
if len(groups) != 2 {
t.Fatalf("got %d groups, want 2", len(groups))
}
// groupHosts sorts group names alphabetically.
if groups[0].name != "alpha" {
t.Fatalf("first group = %q, want alpha", groups[0].name)
}
if groups[1].name != "beta" {
t.Fatalf("second group = %q, want beta", groups[1].name)
}
// All beta hosts should be present (within-group order is determined by filteredServers).
if len(groups[1].hosts) != 2 {
t.Fatalf("beta should have 2 hosts, got %d", len(groups[1].hosts))
}
}
// ---- Icons ----
func TestIconsGetKnownFields(t *testing.T) {
cases := []struct{ name, field string }{
{"servers", Icons.Servers},
{"jobs", Icons.Jobs},
{"config", Icons.Config},
{"hamburger", Icons.Hamburger},
{"clean", Icons.Clean},
{"drift", Icons.Drift},
{"failed", Icons.Failed},
{"play", Icons.Play},
{"scan", Icons.Scan},
{"fix", Icons.Fix},
{"add", Icons.Add},
{"edit", Icons.Edit},
{"ping", Icons.Ping},
}
for _, c := range cases {
got := Icons.Get(c.name)
if got != c.field {
t.Errorf("Icons.Get(%q) = %q, want %q", c.name, got, c.field)
}
if got == "" {
t.Errorf("Icons.Get(%q) returned empty string — glyph not set", c.name)
}
}
}
func TestIconsGetUnknownReturnsEmpty(t *testing.T) {
if got := Icons.Get("nonexistent"); got != "" {
t.Fatalf("Icons.Get(unknown) = %q, want empty", got)
}
}
// ---- driftIcon ----
func TestDriftIconReturnsNonEmpty(t *testing.T) {
states := []string{"clean", "drift", "failed", "unreachable", "scanning", "fixing", "unknown", ""}
for _, s := range states {
if got := driftIcon(s); got == "" {
t.Errorf("driftIcon(%q) returned empty string", s)
}
}
}
func TestDriftIconDistinguishesMajorStates(t *testing.T) {
clean := driftIcon("clean")
drift := driftIcon("drift")
failed := driftIcon("failed")
if clean == drift || drift == failed || clean == failed {
t.Fatalf("driftIcon returned identical icons for different states: clean=%q drift=%q failed=%q",
clean, drift, failed)
}
}
// ---- sidebarWidth ----
func TestSidebarWidthCollapsed(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.sidebarExpanded = false
if w := app.sidebarWidth(); w != 4 {
t.Fatalf("collapsed width = %d, want 4", w)
}
}
func TestSidebarWidthExpanded(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.sidebarExpanded = true
if w := app.sidebarWidth(); w != 14 {
t.Fatalf("expanded width = %d, want 14", w)
}
}
// ---- MenuToggle key ----
func TestMenuToggleKeyFlipsExpanded(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
app.height = 40
if app.sidebarExpanded {
t.Fatal("sidebar should start collapsed")
}
// Press 'm' to expand.
model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}})
app = model.(*App)
if !app.sidebarExpanded {
t.Fatal("sidebar should be expanded after pressing m")
}
// Press 'm' again to collapse.
model, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}})
app = model.(*App)
if app.sidebarExpanded {
t.Fatal("sidebar should be collapsed after pressing m twice")
}
}
// ---- Fleet Summary shows compliance results ----
func TestFleetSummaryShowsComplianceResultsAfterScan(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{
Hosts: []*inventory.Host{{Name: "web-1", DriftState: "drift"}},
}, history.New(t.TempDir()))
app.width = 120
app.height = 40
// Simulate completed compliance job results.
app.complianceJobResults = []complianceJobResult{
{label: "all", playbook: "playbooks/site.yml", status: "clean"},
{label: "web", playbook: "playbooks/webservers.yml", status: "drift", changed: 3},
}
rendered := app.renderFleetSummaryPanel(app.width, 10)
// Both playbook names should appear in the output.
if !strings.Contains(rendered, "site") {
t.Error("fleet summary should show site.yml result")
}
if !strings.Contains(rendered, "webservers") {
t.Error("fleet summary should show webservers.yml result")
}
}
func TestFleetSummaryShowsProgressDuringComplianceScan(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
app.height = 40
app.complianceRunning = true
app.statusMsg = "checking playbooks/site.yml (job 1/2)"
rendered := app.renderFleetSummaryPanel(app.width, 10)
if !strings.Contains(rendered, "site.yml") {
t.Error("fleet summary should show current job during scan")
}
}
func TestFleetSummaryHidesOldMappedChecksLine(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
app.height = 40
rendered := app.renderFleetSummaryPanel(app.width, 10)
if strings.Contains(rendered, "mapped compliance checks") {
t.Error("fleet summary should not show 'mapped compliance checks' line")
}
}
// ---- Title bar has no paths ----
func TestTitleBarNoPathsShown(t *testing.T) {
cfg := &config.Config{PlaybookDir: "/home/user/playbooks", InventoryPath: "/home/user/inventory.yml"}
app := New(cfg, &inventory.Inventory{}, history.New(t.TempDir()))
app.width = 120
rendered := app.renderTitleBar(120)
if strings.Contains(rendered, "/home/user/playbooks") {
t.Error("title bar should not show playbook path")
}
if strings.Contains(rendered, "/home/user/inventory.yml") {
t.Error("title bar should not show inventory path")
}
if !strings.Contains(rendered, "ansibleTUI") {
t.Error("title bar should still show app name")
}
}
// ---- Server Detail is simplified ----
func TestServerDetailNoIPGroupTimestamp(t *testing.T) {
host := &inventory.Host{
Name: "web-1",
AnsibleHost: "10.0.0.1",
Group: "web",
DriftState: "drift",
LastCheck: "12:00",
LastApply: "11:50",
}
app := New(&config.Config{}, &inventory.Inventory{Hosts: []*inventory.Host{host}}, history.New(t.TempDir()))
app.width = 120
app.height = 40
app.serverCursor = 0
app.activePanel = PanelServers
rendered := app.renderServerDetail(40, 10)
if strings.Contains(rendered, "10.0.0.1") {
t.Error("server detail should not show IP address")
}
if strings.Contains(rendered, "Group") {
t.Error("server detail should not show Group label")
}
if strings.Contains(rendered, "12:00") {
t.Error("server detail should not show last-check timestamp")
}
if strings.Contains(rendered, "11:50") {
t.Error("server detail should not show last-apply timestamp")
}
if !strings.Contains(rendered, "web-1") {
t.Error("server detail must show server name")
}
}
// ---- Recent Runs panel: 1 line per run ----
func TestRecentRunsOneLinePerRun(t *testing.T) {
hist := history.New(t.TempDir())
app := New(&config.Config{}, &inventory.Inventory{}, hist)
app.width = 120
app.height = 40
app.runs = []*history.RunRecord{
{Playbook: "playbooks/site.yml", Host: "all", Mode: "check+diff", Status: "clean"},
{Playbook: "playbooks/dns.yml", Host: "dns", Mode: "check", Status: "drift"},
{Playbook: "playbooks/site.yml", Host: "all", Mode: "apply", Status: "failed"},
}
rendered := app.renderRecentRunsPanel(50, 15)
// Search the raw rendered string — ANSI sequences don't contain playbook names,
// so this works without stripping escape codes.
if !strings.Contains(rendered, "site") {
t.Error("rendered runs panel should contain 'site'")
}
if !strings.Contains(rendered, "dns") {
t.Error("rendered runs panel should contain 'dns'")
}
}
func TestRecentRunsSeqNumberDescending(t *testing.T) {
// The sequence number logic: runs[0] is newest, gets highest seq number.
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.runs = []*history.RunRecord{
{Playbook: "playbooks/a.yml", Host: "h1", Mode: "check", Status: "clean"},
{Playbook: "playbooks/b.yml", Host: "h2", Mode: "check", Status: "drift"},
}
// runs[0] → seqNum = len(runs) - 0 = 2
// runs[1] → seqNum = len(runs) - 1 = 1
seqFirst := len(app.runs) - 0
seqSecond := len(app.runs) - 1
if seqFirst <= seqSecond {
t.Fatalf("first run seq %d should be > second run seq %d", seqFirst, seqSecond)
}
}
func TestRecentRunsStylesDifferByStatus(t *testing.T) {
// Verify that different statuses are mapped to different lipgloss styles
// by checking that the foreground colors differ.
if runLineCleanStyle.GetForeground() == runLineDriftStyle.GetForeground() {
t.Error("clean and drift run styles should have different foreground colors")
}
if runLineCleanStyle.GetForeground() == runLineFailedStyle.GetForeground() {
t.Error("clean and failed run styles should have different foreground colors")
}
if runLineDriftStyle.GetForeground() == runLineFailedStyle.GetForeground() {
t.Error("drift and failed run styles should have different foreground colors")
}
}
+44 -42
View File
@@ -3,49 +3,51 @@ package ui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Filter key.Binding
Add key.Binding
Edit key.Binding
Ping key.Binding
Check key.Binding
Apply key.Binding
Fix key.Binding
Enter key.Binding
Back key.Binding
Quit key.Binding
Tab key.Binding
ShiftTab key.Binding
Delete key.Binding
Sync key.Binding
Push key.Binding
Toggle key.Binding
Logs key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Filter key.Binding
Add key.Binding
Edit key.Binding
Ping key.Binding
Check key.Binding
Apply key.Binding
Fix key.Binding
Enter key.Binding
Back key.Binding
Quit key.Binding
Tab key.Binding
ShiftTab key.Binding
Delete key.Binding
Sync key.Binding
Push key.Binding
Toggle key.Binding
Logs key.Binding
MenuToggle key.Binding // toggle sidebar expand/collapse
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k")),
Down: key.NewBinding(key.WithKeys("down", "j")),
Left: key.NewBinding(key.WithKeys("left", "h")),
Right: key.NewBinding(key.WithKeys("right", "l")),
Filter: key.NewBinding(key.WithKeys("/")),
Add: key.NewBinding(key.WithKeys("a")),
Edit: key.NewBinding(key.WithKeys("e")),
Ping: key.NewBinding(key.WithKeys("p")),
Check: key.NewBinding(key.WithKeys("c")),
Apply: key.NewBinding(key.WithKeys("r")),
Fix: key.NewBinding(key.WithKeys("f")),
Enter: key.NewBinding(key.WithKeys("enter")),
Back: key.NewBinding(key.WithKeys("esc")),
Quit: key.NewBinding(key.WithKeys("q")),
Tab: key.NewBinding(key.WithKeys("tab")),
ShiftTab: key.NewBinding(key.WithKeys("shift+tab")),
Delete: key.NewBinding(key.WithKeys("d")),
Sync: key.NewBinding(key.WithKeys("s")),
Push: key.NewBinding(key.WithKeys("S")),
Toggle: key.NewBinding(key.WithKeys("t")),
Logs: key.NewBinding(key.WithKeys("o")),
Up: key.NewBinding(key.WithKeys("up", "k")),
Down: key.NewBinding(key.WithKeys("down", "j")),
Left: key.NewBinding(key.WithKeys("left", "h")),
Right: key.NewBinding(key.WithKeys("right", "l")),
Filter: key.NewBinding(key.WithKeys("/")),
Add: key.NewBinding(key.WithKeys("a")),
Edit: key.NewBinding(key.WithKeys("e")),
Ping: key.NewBinding(key.WithKeys("p")),
Check: key.NewBinding(key.WithKeys("c")),
Apply: key.NewBinding(key.WithKeys("r")),
Fix: key.NewBinding(key.WithKeys("f")),
Enter: key.NewBinding(key.WithKeys("enter")),
Back: key.NewBinding(key.WithKeys("esc")),
Quit: key.NewBinding(key.WithKeys("q")),
Tab: key.NewBinding(key.WithKeys("tab")),
ShiftTab: key.NewBinding(key.WithKeys("shift+tab")),
Delete: key.NewBinding(key.WithKeys("d")),
Sync: key.NewBinding(key.WithKeys("s")),
Push: key.NewBinding(key.WithKeys("S")),
Toggle: key.NewBinding(key.WithKeys("t")),
Logs: key.NewBinding(key.WithKeys("o")),
MenuToggle: key.NewBinding(key.WithKeys("m")),
}
+11
View File
@@ -9,6 +9,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/history"
)
@@ -60,6 +61,15 @@ func (a *App) viewRunDetails() string {
recap += " (check mode: changed = would change, not applied)"
}
// Build the full command so the user can copy and run it manually.
argv := ansible.BuildPlaybookArgs(
a.cfg.EffectiveInventoryPath(),
run.Playbook,
run.Host,
ansible.ModeFromString(run.Mode),
)
cmdStr := strings.Join(argv, " ")
meta := strings.Join([]string{
subtleStyle.Render("Playbook: ") + boldStyle.Render(run.Playbook),
subtleStyle.Render("Host: ") + boldStyle.Render(run.Host),
@@ -68,6 +78,7 @@ func (a *App) viewRunDetails() string {
subtleStyle.Render("Recap: ") + dimStyle.Render(recap),
subtleStyle.Render("Summary: ") + dimStyle.Render(a.runFindingSummary()),
subtleStyle.Render("Started: ") + run.StartTime.Format("2006-01-02 15:04:05"),
subtleStyle.Render("Command: ") + dimStyle.Render(cmdStr),
}, "\n")
a.logVp.Width = w - 4
+114 -11
View File
@@ -80,13 +80,18 @@ var (
// Recent run list
var (
runTimeStyle = lipgloss.NewStyle().Foreground(colorSubtle)
runPlaybookStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
runSelectedBg = lipgloss.NewStyle().Background(colorSelected)
runChangedStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
runCleanStyle = lipgloss.NewStyle().Foreground(colorGreen)
runFailedStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
runUnreachStyle = lipgloss.NewStyle().Foreground(colorRed)
runTimeStyle = lipgloss.NewStyle().Foreground(colorSubtle)
runSelectedBg = lipgloss.NewStyle().Background(colorSelected)
runChangedStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
runCleanStyle = lipgloss.NewStyle().Foreground(colorGreen)
runFailedStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
runUnreachStyle = lipgloss.NewStyle().Foreground(colorRed)
// Full-line RAG styles for the condensed 1-line run rows.
runLineCleanStyle = lipgloss.NewStyle().Foreground(colorGreen)
runLineDriftStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
runLineFailedStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
runLineUnreachStyle = lipgloss.NewStyle().Foreground(colorRed)
)
// Fleet summary badges
@@ -145,7 +150,78 @@ var (
// Divider line
var dividerStyle = lipgloss.NewStyle().Foreground(colorBorder)
// renderReachable renders the reachable field with appropriate color.
// ---- Nerd Font icon set ----
// iconSet holds all Nerd Font glyphs used across the UI.
// Call sites use Icons.Play, Icons.Scan, etc.
// Get(name) provides dynamic lookup by lowercase field name.
type iconSet struct {
Servers string // nf-fa-server
Jobs string // nf-fa-list
Config string // nf-fa-cog
Hamburger string // nf-fa-bars
Clean string // nf-fa-check
Drift string // nf-fa-exclamation-triangle
Failed string // nf-fa-times
Unreachable string // nf-fa-chain-broken
Play string // nf-fa-play
Scan string // nf-fa-search
Fix string // nf-fa-wrench
Add string // nf-fa-plus
Edit string // nf-fa-pencil
Delete string // nf-fa-trash
Ping string // nf-fa-wifi
Git string // nf-dev-git_branch
Running string // nf-fa-spinner
}
// Get returns the icon for the given lowercase field name, or "" if not found.
func (s iconSet) Get(name string) string {
m := map[string]string{
"servers": s.Servers,
"jobs": s.Jobs,
"config": s.Config,
"hamburger": s.Hamburger,
"clean": s.Clean,
"drift": s.Drift,
"failed": s.Failed,
"unreachable": s.Unreachable,
"play": s.Play,
"scan": s.Scan,
"fix": s.Fix,
"add": s.Add,
"edit": s.Edit,
"delete": s.Delete,
"ping": s.Ping,
"git": s.Git,
"running": s.Running,
}
return m[name]
}
// Icons is the package-level singleton used by all renderers.
var Icons = iconSet{
Servers: "", // nf-fa-server
Jobs: "", // nf-fa-list
Config: "", // nf-fa-cog
Hamburger: "", // nf-fa-bars
Clean: "", // nf-fa-check
Drift: "", // nf-fa-exclamation-triangle
Failed: "", // nf-fa-times
Unreachable: "", // nf-fa-chain-broken
Play: "", // nf-fa-play
Scan: "", // nf-fa-search
Fix: "", // nf-fa-wrench
Add: "", // nf-fa-plus
Edit: "", // nf-fa-pencil
Delete: "", // nf-fa-trash
Ping: "", // nf-fa-wifi
Git: "", // nf-dev-git_branch
Running: "", // nf-fa-spinner
}
// ---- Render helpers ----
func renderReachable(r *bool) string {
if r == nil {
return statusUnknown.Render("unknown")
@@ -156,7 +232,6 @@ func renderReachable(r *bool) string {
return statusFailed.Render("failed")
}
// renderDrift renders a drift state with appropriate color.
func renderDrift(state string) string {
switch state {
case "clean":
@@ -174,7 +249,36 @@ func renderDrift(state string) string {
}
}
// renderRunStatus renders a run status with appropriate color.
// driftIcon returns the icon corresponding to a host drift state.
func driftIcon(state string) string {
switch state {
case "clean":
return Icons.Clean
case "drift":
return Icons.Drift
case "failed", "unreachable":
return Icons.Failed
case "scanning", "fixing":
return Icons.Running
default:
return Icons.Servers
}
}
// driftNameStyle returns a lipgloss style for a server name based on its drift state.
func driftNameStyle(state string) lipgloss.Style {
switch state {
case "clean":
return lipgloss.NewStyle().Bold(true).Foreground(colorGreen)
case "drift":
return lipgloss.NewStyle().Bold(true).Foreground(colorYellow)
case "failed", "unreachable":
return lipgloss.NewStyle().Bold(true).Foreground(colorRed)
default:
return lipgloss.NewStyle().Bold(true).Foreground(colorCyan)
}
}
func renderRunStatus(status string) string {
switch status {
case "clean", "ok":
@@ -190,7 +294,6 @@ func renderRunStatus(status string) string {
}
}
// renderRunStatusForCheck labels check-mode outcomes (changed means would-change).
func renderRunStatusForCheck(status string, changed int) string {
switch status {
case "clean":