From 631f07c242e673e37cf5bde3bae0fe1546979ef9 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 30 Jun 2026 20:36:25 -0400 Subject: [PATCH] feat(tui): stable detail panel height with scroll support Detail sidebar now maintains consistent height matching the monitors panel across all modes (default, SLA, history). Content scrolls within the fixed frame via mouse wheel or j/k when the detail panel is focused. Scroll offset resets on mode/monitor/period changes. Logs strip stays visible in all states. --- internal/tui/panel.go | 67 ++++++++++++++++++++++++++---- internal/tui/tui.go | 11 ++--- internal/tui/update.go | 32 ++++++++++++++ internal/tui/view_dashboard.go | 2 +- internal/tui/view_detail_inline.go | 18 +------- 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/internal/tui/panel.go b/internal/tui/panel.go index a8480f1..55a3291 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -6,16 +6,69 @@ import ( "github.com/charmbracelet/lipgloss" ) -func (m Model) titledPanelH(title, content string, width, height int, focused bool) string { - s := m.titledPanel(title, content, width, focused) +func (m Model) titledPanelH(title, content string, width, height, scrollOffset int, focused bool) string { if height <= 0 { - return s + return m.titledPanel(title, content, width, focused) } - lines := strings.Split(s, "\n") - if len(lines) <= height { - return s + + borderColor := m.theme.Border + titleColor := m.theme.Muted + if focused { + borderColor = m.theme.Accent + titleColor = m.theme.Accent } - return strings.Join(lines[:height-1], "\n") + "\n" + lines[len(lines)-1] + + bc := lipgloss.NewStyle().Foreground(borderColor) + tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true) + + innerW := width - 2 + if innerW < 10 { + innerW = 10 + } + + titleRendered := tc.Render(" " + title + " ") + titleLen := len([]rune(title)) + 2 + fillLen := innerW - titleLen - 1 + if fillLen < 0 { + fillLen = 0 + } + + top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮") + bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯") + + contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW) + inner := contentStyle.Render(content) + contentLines := strings.Split(inner, "\n") + + bodyH := height - 2 + if bodyH < 1 { + bodyH = 1 + } + + if scrollOffset > len(contentLines)-bodyH { + scrollOffset = len(contentLines) - bodyH + } + if scrollOffset < 0 { + scrollOffset = 0 + } + + end := scrollOffset + bodyH + if end > len(contentLines) { + end = len(contentLines) + } + visible := contentLines[scrollOffset:end] + + var lines []string + lines = append(lines, top) + for _, line := range visible { + lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│")) + } + for len(lines) < height-1 { + lines = append(lines, bc.Render("│")+strings.Repeat(" ", innerW)+bc.Render("│")) + } + lines = append(lines, bottom) + + return strings.Join(lines, "\n") } func (m Model) titledPanel(title, content string, width int, focused bool) string { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 77c2834..d54d23e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -160,11 +160,12 @@ type Model struct { historySiteName string historySiteID int - slaReport monitor.SLAReport - slaDailyBreakdown []monitor.DayReport - slaSiteName string - slaSiteID int - slaPeriodIdx int + slaReport monitor.SLAReport + slaDailyBreakdown []monitor.DayReport + slaSiteName string + slaSiteID int + slaPeriodIdx int + detailScrollOffset int isAdmin bool zones *zone.Manager diff --git a/internal/tui/update.go b/internal/tui/update.go index 9ddcbb6..97e9183 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -298,6 +298,18 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.focusedPanel == panelDetail && m.detailOpen { + if msg.Button == tea.MouseButtonWheelUp { + m.detailScrollOffset -= 3 + } else { + m.detailScrollOffset += 3 + } + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + return m, nil + } + listLen := m.currentListLen() if msg.Button == tea.MouseButtonWheelUp { if m.cursor > 0 { @@ -588,6 +600,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.recalcLayout() case "up", "k": + if m.focusedPanel == panelDetail && m.detailOpen { + m.detailScrollOffset-- + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + return m, nil + } if m.focusedPanel == panelMaint { m.scrollMaintCursor(-1) return m, nil @@ -604,10 +623,15 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.syncSelectedID() if m.detailOpen && m.cursor < len(m.sites) { m.detailMode = detailDefault + m.detailScrollOffset = 0 return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } case "down", "j": + if m.focusedPanel == panelDetail && m.detailOpen { + m.detailScrollOffset++ + return m, nil + } if m.focusedPanel == panelMaint { m.scrollMaintCursor(1) return m, nil @@ -625,6 +649,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.syncSelectedID() if m.detailOpen && m.cursor < len(m.sites) { m.detailMode = detailDefault + m.detailScrollOffset = 0 return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } @@ -652,6 +677,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.sites) > 0 { m.detailOpen = !m.detailOpen m.detailMode = detailDefault + m.detailScrollOffset = 0 m.recalcLayout() st := m.store ctx := m.ctx @@ -702,9 +728,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focusedPanel = panelMonitors } else if m.detailOpen && m.detailMode != detailDefault { m.detailMode = detailDefault + m.detailScrollOffset = 0 } else if m.detailOpen { m.detailOpen = false m.detailMode = detailDefault + m.detailScrollOffset = 0 m.recalcLayout() st := m.store ctx := m.ctx @@ -719,6 +747,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.historySiteID = site.ID m.historyChanges = nil m.detailMode = detailHistory + m.detailScrollOffset = 0 return m, m.loadHistoryCmd(site.ID) } case "s": @@ -728,6 +757,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.slaSiteID = site.ID m.slaPeriodIdx = 2 m.detailMode = detailSLA + m.detailScrollOffset = 0 return m, m.loadSLACmd(site.ID, m.slaPeriodIdx) } case "1", "2", "3", "4": @@ -735,6 +765,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { idx := int(msg.String()[0]-'0') - 1 if idx >= 0 && idx < len(slaPeriods) { m.slaPeriodIdx = idx + m.detailScrollOffset = 0 return m, m.loadSLACmd(m.slaSiteID, idx) } } @@ -860,6 +891,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.syncSelectedID() m.focusedPanel = panelMonitors if m.detailOpen { + m.detailScrollOffset = 0 return m, m.loadDetailCmd(m.sites[m.cursor].ID) } return m, nil diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 24fd8eb..6b81089 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -192,7 +192,7 @@ func (m Model) viewMonitorsLayout() string { } monHeight := lipgloss.Height(monPanel) detail := m.viewDetailInline(detailW-2, monHeight) - detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.focusedPanel == panelDetail)) + detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) topParts = append(topParts, detailPanel) } diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 22e5834..ca5cc33 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -235,7 +235,7 @@ func (m Model) fmtStatusWord(status string) string { } } -func (m Model) viewSLASidebar(width, height int) string { +func (m Model) viewSLASidebar(width, _ int) string { var b strings.Builder label := m.st.subtleStyle innerW := width - 4 @@ -277,21 +277,7 @@ func (m Model) viewSLASidebar(width, height int) string { if dayBarW < 10 { dayBarW = 10 } - slaChrome := 14 - if r.OutageCount > 0 { - slaChrome += 3 - } - maxDaily := len(m.slaDailyBreakdown) - if height > 0 && height-slaChrome < maxDaily { - maxDaily = height - slaChrome - if maxDaily < 1 { - maxDaily = 1 - } - } - for i, day := range m.slaDailyBreakdown { - if i >= maxDaily { - break - } + for _, day := range m.slaDailyBreakdown { dateStr := day.Date.Format("Jan 02") dayBar := m.uptimeBar(day.UptimePct, dayBarW) pctStr := fmtPct(day.UptimePct) + "%"