feat: Stage 2 — alert dashboard with live table and stats
Adds a bubbles/table alert list showing Name, Status, Started (relative time), and Ack By columns. Stats bar shows total/firing/resolved counts. Filter cycles firing → resolved → all with [f]. Auto-refresh fires on the configured interval via tea.Tick. Table scrolls with j/k.
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -58,6 +60,38 @@ func (c *Client) do(req *http.Request, out any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAlerts fetches alerts from the server. status may be "firing", "resolved", or "" for all.
|
||||||
|
func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
if status != "" {
|
||||||
|
q.Set("status", status)
|
||||||
|
}
|
||||||
|
if limit > 0 {
|
||||||
|
q.Set("limit", strconv.Itoa(limit))
|
||||||
|
}
|
||||||
|
path := "/api/alerts"
|
||||||
|
if len(q) > 0 {
|
||||||
|
path += "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.newRequest(http.MethodGet, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var alerts []Alert
|
||||||
|
return alerts, c.do(req, &alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlertStats fetches aggregate alert counts.
|
||||||
|
func (c *Client) GetAlertStats() (*AlertStats, error) {
|
||||||
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var stats AlertStats
|
||||||
|
return &stats, c.do(req, &stats)
|
||||||
|
}
|
||||||
|
|
||||||
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
|
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
|
||||||
func (c *Client) HealthCheck() error {
|
func (c *Client) HealthCheck() error {
|
||||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Alert struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
Annotations map[string]string `json:"annotations"`
|
||||||
|
StartsAt time.Time `json:"starts_at"`
|
||||||
|
EndsAt *time.Time `json:"ends_at"`
|
||||||
|
GeneratorURL string `json:"generator_url"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
AcknowledgedByID *int64 `json:"acknowledged_by_id"`
|
||||||
|
AcknowledgedBy string `json:"acknowledged_by"`
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertStats struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Firing int `json:"firing"`
|
||||||
|
Resolved int `json:"resolved"`
|
||||||
|
}
|
||||||
+112
-12
@@ -1,10 +1,13 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/yeniklas/terdut-tui/internal/api"
|
"github.com/yeniklas/terdut-tui/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,19 +19,13 @@ const (
|
|||||||
sectionUsers
|
sectionUsers
|
||||||
)
|
)
|
||||||
|
|
||||||
type mode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
modeDashboard mode = iota
|
|
||||||
modeDetail
|
|
||||||
modeSchedule
|
|
||||||
modeUsers
|
|
||||||
)
|
|
||||||
|
|
||||||
// tea.Msg types
|
// tea.Msg types
|
||||||
|
|
||||||
type connectedMsg struct{ serverURL string }
|
type connectedMsg struct{}
|
||||||
type connectErrMsg struct{ err error }
|
type connectErrMsg struct{ err error }
|
||||||
|
type alertsFetchedMsg struct{ alerts []api.Alert }
|
||||||
|
type statsFetchedMsg struct{ stats api.AlertStats }
|
||||||
|
type fetchDataErrMsg struct{ err error }
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
type clearStatusMsg struct{}
|
type clearStatusMsg struct{}
|
||||||
|
|
||||||
@@ -39,25 +36,41 @@ type Model struct {
|
|||||||
refreshInterval time.Duration
|
refreshInterval time.Duration
|
||||||
|
|
||||||
activeSection section
|
activeSection section
|
||||||
mode mode
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
connected bool
|
connected bool
|
||||||
|
loading bool
|
||||||
err error
|
err error
|
||||||
statusMsg string
|
statusMsg string
|
||||||
|
|
||||||
|
alerts []api.Alert
|
||||||
|
stats *api.AlertStats
|
||||||
|
filterStatus string // "firing", "resolved", or "" (all)
|
||||||
|
|
||||||
|
alertTable table.Model
|
||||||
help help.Model
|
help help.Model
|
||||||
keys keyMap
|
keys keyMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
||||||
|
t := table.New(table.WithFocused(true))
|
||||||
|
s := table.DefaultStyles()
|
||||||
|
s.Header = s.Header.Bold(true)
|
||||||
|
s.Selected = s.Selected.
|
||||||
|
Foreground(lipgloss.Color("0")).
|
||||||
|
Background(colorPrimary).
|
||||||
|
Bold(true)
|
||||||
|
t.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
client: client,
|
client: client,
|
||||||
serverURL: serverURL,
|
serverURL: serverURL,
|
||||||
refreshInterval: refreshInterval,
|
refreshInterval: refreshInterval,
|
||||||
activeSection: sectionAlerts,
|
activeSection: sectionAlerts,
|
||||||
mode: modeDashboard,
|
loading: true,
|
||||||
|
filterStatus: "firing",
|
||||||
|
alertTable: t,
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
keys: keys,
|
keys: keys,
|
||||||
}
|
}
|
||||||
@@ -67,6 +80,73 @@ func (m Model) Init() tea.Cmd {
|
|||||||
return connectCmd(m.client)
|
return connectCmd(m.client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rebuildTable updates the alert table columns, rows, and height to match current state.
|
||||||
|
func (m *Model) rebuildTable() {
|
||||||
|
m.alertTable.SetColumns(alertColumns(m.width))
|
||||||
|
m.alertTable.SetRows(alertRows(m.alerts))
|
||||||
|
h := m.height - 8 // header + tabs + sep + stats + table-header + footer + 2 margins
|
||||||
|
if h < 1 {
|
||||||
|
h = 1
|
||||||
|
}
|
||||||
|
m.alertTable.SetHeight(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertColumns(width int) []table.Column {
|
||||||
|
nameW := width/2 - 8
|
||||||
|
if nameW < 20 {
|
||||||
|
nameW = 20
|
||||||
|
}
|
||||||
|
ackW := width - nameW - 10 - 12 - 6
|
||||||
|
if ackW < 8 {
|
||||||
|
ackW = 8
|
||||||
|
}
|
||||||
|
return []table.Column{
|
||||||
|
{Title: "Name", Width: nameW},
|
||||||
|
{Title: "Status", Width: 10},
|
||||||
|
{Title: "Started", Width: 12},
|
||||||
|
{Title: "Ack By", Width: ackW},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertRows(alerts []api.Alert) []table.Row {
|
||||||
|
now := time.Now()
|
||||||
|
rows := make([]table.Row, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
ack := a.AcknowledgedBy
|
||||||
|
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), ack}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanAgo(now, t time.Time) string {
|
||||||
|
d := now.Sub(t)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if m == 0 {
|
||||||
|
return fmt.Sprintf("%dh ago", h)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %dm ago", h, m)
|
||||||
|
default:
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
h := int(d.Hours()) % 24
|
||||||
|
if h == 0 {
|
||||||
|
return fmt.Sprintf("%dd ago", days)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dd %dh ago", days, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command constructors
|
||||||
|
|
||||||
func connectCmd(client *api.Client) tea.Cmd {
|
func connectCmd(client *api.Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := client.HealthCheck(); err != nil {
|
if err := client.HealthCheck(); err != nil {
|
||||||
@@ -76,6 +156,26 @@ func connectCmd(client *api.Client) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
alerts, err := client.ListAlerts(status, 500)
|
||||||
|
if err != nil {
|
||||||
|
return fetchDataErrMsg{err}
|
||||||
|
}
|
||||||
|
return alertsFetchedMsg{alerts}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStatsCmd(client *api.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
stats, err := client.GetAlertStats()
|
||||||
|
if err != nil {
|
||||||
|
return fetchDataErrMsg{err}
|
||||||
|
}
|
||||||
|
return statsFetchedMsg{*stats}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tickCmd(interval time.Duration) tea.Cmd {
|
func tickCmd(interval time.Duration) tea.Cmd {
|
||||||
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
|
|||||||
+58
-8
@@ -9,27 +9,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
m.rebuildTable()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case connectedMsg:
|
case connectedMsg:
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.statusMsg = "Connected to " + m.serverURL
|
return m, tea.Batch(
|
||||||
return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd())
|
tickCmd(m.refreshInterval),
|
||||||
|
fetchAlertsCmd(m.client, m.filterStatus),
|
||||||
|
fetchStatsCmd(m.client),
|
||||||
|
)
|
||||||
|
|
||||||
case connectErrMsg:
|
case connectErrMsg:
|
||||||
m.connected = false
|
m.connected = false
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case alertsFetchedMsg:
|
||||||
|
m.alerts = msg.alerts
|
||||||
|
m.loading = false
|
||||||
|
m.rebuildTable()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case statsFetchedMsg:
|
||||||
|
m.stats = &msg.stats
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case fetchDataErrMsg:
|
||||||
|
m.statusMsg = "error: " + msg.err.Error()
|
||||||
|
return m, clearStatusCmd()
|
||||||
|
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
return m, tickCmd(m.refreshInterval)
|
return m, tea.Batch(
|
||||||
|
tickCmd(m.refreshInterval),
|
||||||
|
fetchAlertsCmd(m.client, m.filterStatus),
|
||||||
|
fetchStatsCmd(m.client),
|
||||||
|
)
|
||||||
|
|
||||||
case clearStatusMsg:
|
case clearStatusMsg:
|
||||||
m.statusMsg = ""
|
m.statusMsg = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
if m.activeSection == sectionAlerts && m.connected {
|
||||||
|
var tableCmd tea.Cmd
|
||||||
|
m.alertTable, tableCmd = m.alertTable.Update(msg)
|
||||||
|
m2, ourCmd := m.handleKey(msg)
|
||||||
|
return m2, tea.Batch(tableCmd, ourCmd)
|
||||||
|
}
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +65,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch {
|
switch msg.String() {
|
||||||
case msg.String() == "q" || msg.String() == "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case msg.String() == "tab":
|
case "tab":
|
||||||
m.activeSection = (m.activeSection + 1) % 3
|
m.activeSection = (m.activeSection + 1) % 3
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case msg.String() == "r":
|
case "r":
|
||||||
|
if !m.connected {
|
||||||
|
return m, connectCmd(m.client)
|
||||||
|
}
|
||||||
m.statusMsg = "Refreshing…"
|
m.statusMsg = "Refreshing…"
|
||||||
return m, tea.Batch(connectCmd(m.client), clearStatusCmd())
|
return m, tea.Batch(
|
||||||
|
fetchAlertsCmd(m.client, m.filterStatus),
|
||||||
|
fetchStatsCmd(m.client),
|
||||||
|
clearStatusCmd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
if m.activeSection != sectionAlerts || !m.connected {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch m.filterStatus {
|
||||||
|
case "firing":
|
||||||
|
m.filterStatus = "resolved"
|
||||||
|
case "resolved":
|
||||||
|
m.filterStatus = ""
|
||||||
|
default:
|
||||||
|
m.filterStatus = "firing"
|
||||||
|
}
|
||||||
|
m.loading = true
|
||||||
|
return m, fetchAlertsCmd(m.client, m.filterStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
+64
-28
@@ -24,11 +24,7 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
func (m Model) renderHeader() string {
|
func (m Model) renderHeader() string {
|
||||||
title := styleHeader.Render("terdut-tui")
|
title := styleHeader.Render("terdut-tui")
|
||||||
right := ""
|
right := styleMuted.Render(m.serverURL)
|
||||||
if m.serverURL != "" {
|
|
||||||
right = styleMuted.Render(m.serverURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
|
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
|
||||||
if gap < 0 {
|
if gap < 0 {
|
||||||
gap = 0
|
gap = 0
|
||||||
@@ -45,43 +41,83 @@ func (m Model) renderTabs() string {
|
|||||||
tabs = append(tabs, styleTabInactive.Render(name))
|
tabs = append(tabs, styleTabInactive.Render(name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
line := strings.Repeat("─", m.width)
|
sep := styleMuted.Render(strings.Repeat("─", m.width))
|
||||||
return strings.Join(tabs, "") + "\n" + styleMuted.Render(line)
|
return strings.Join(tabs, "") + "\n" + sep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderBody() string {
|
func (m Model) renderBody() string {
|
||||||
bodyHeight := m.height - 5 // header + tabs + separator + footer + help
|
if m.err != nil {
|
||||||
if bodyHeight < 1 {
|
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) +
|
||||||
bodyHeight = 1
|
"\n" + styleMuted.Render(" Press r to retry.")
|
||||||
|
}
|
||||||
|
if !m.connected {
|
||||||
|
return "\n" + styleMuted.Render(" Connecting…")
|
||||||
}
|
}
|
||||||
|
|
||||||
var content string
|
|
||||||
if m.err != nil {
|
|
||||||
content = styleError.Render(fmt.Sprintf("Error: %v", m.err))
|
|
||||||
} else if !m.connected {
|
|
||||||
content = styleMuted.Render("Connecting…")
|
|
||||||
} else if m.statusMsg != "" {
|
|
||||||
content = styleStatus.Render(m.statusMsg)
|
|
||||||
} else {
|
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
case sectionAlerts:
|
case sectionAlerts:
|
||||||
content = styleMuted.Render("Alert dashboard — coming in Stage 2")
|
return m.renderAlerts()
|
||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
content = styleMuted.Render("On-call schedule — coming in Stage 4")
|
return "\n" + styleMuted.Render(" On-call schedule — coming in Stage 4")
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
content = styleMuted.Render("User management — coming in Stage 5")
|
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderAlerts() string {
|
||||||
|
statsBar := m.renderStatsBar()
|
||||||
|
var content string
|
||||||
|
if m.loading && len(m.alerts) == 0 {
|
||||||
|
content = styleMuted.Render(" Loading alerts…")
|
||||||
|
} else if len(m.alerts) == 0 {
|
||||||
|
label := m.filterStatus
|
||||||
|
if label == "" {
|
||||||
|
label = "all"
|
||||||
|
}
|
||||||
|
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
|
||||||
|
} else {
|
||||||
|
content = m.alertTable.View()
|
||||||
|
}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderStatsBar() string {
|
||||||
|
total, firing, resolved := 0, 0, 0
|
||||||
|
if m.stats != nil {
|
||||||
|
total = m.stats.Total
|
||||||
|
firing = m.stats.Firing
|
||||||
|
resolved = m.stats.Resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
filterLabel := m.filterStatus
|
||||||
padding := (bodyHeight - len(lines)) / 2
|
if filterLabel == "" {
|
||||||
if padding < 0 {
|
filterLabel = "all"
|
||||||
padding = 0
|
|
||||||
}
|
}
|
||||||
return strings.Repeat("\n", padding) + content
|
|
||||||
|
left := fmt.Sprintf(" Total: %d %s %s",
|
||||||
|
total,
|
||||||
|
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
|
||||||
|
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||||||
|
)
|
||||||
|
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
|
||||||
|
|
||||||
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
|
if gap < 0 {
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderFooter() string {
|
func (m Model) renderFooter() string {
|
||||||
helpView := m.help.ShortHelpView(m.keys.ShortHelp())
|
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||||
return "\n" + styleFooter.Render(helpView)
|
if m.statusMsg != "" {
|
||||||
|
status := styleStatus.Render(m.statusMsg)
|
||||||
|
gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView)
|
||||||
|
if gap < 0 {
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
return "\n" + status + strings.Repeat(" ", gap) + helpView
|
||||||
|
}
|
||||||
|
return "\n" + helpView
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user