Compare commits

...

2 Commits

Author SHA1 Message Date
lerko 351848d407 feat(tui): add scrollbar gutter to titledPanelH
Opt-in scrollbar track on the right border edge when totalItems > bodyH.
Thin track (│) with muted thumb (┃) showing viewport position.

Monitors panel passes len(sites) to enable it. Detail and fullscreen
panels pass 0 to opt out. Any panel can opt in via the totalItems param.
2026-07-01 21:04:39 -04:00
lerko 14cec4283d feat(tui): persist bottom panel preference across restarts
Save bottom_panel pref (logs/maint/none) to store on toggle.
Restore on startup via InitialModel, same pattern as detail_open.
2026-07-01 21:02:45 -04:00
6 changed files with 67 additions and 13 deletions
+15
View File
@@ -50,6 +50,21 @@ func writeCmd(op string, fn func() error) tea.Cmd {
} }
} }
func (m *Model) saveBottomPanelPref() tea.Cmd {
v := "logs"
switch m.bottomPanel {
case bottomNone:
v = "none"
case bottomMaint:
v = "maint"
}
st := m.store
ctx := m.ctx
return writeCmd("Save bottom panel preference", func() error {
return st.SetPreference(ctx, "bottom_panel", v)
})
}
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site { func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
var groups, ungrouped []models.Site var groups, ungrouped []models.Site
children := make(map[int][]models.Site) children := make(map[int][]models.Site)
+36 -9
View File
@@ -6,7 +6,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string { func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset, totalItems int, focused bool) string {
if height <= 0 { if height <= 0 {
return m.titledPanel(title, content, width, focused) return m.titledPanel(title, content, width, focused)
} }
@@ -64,21 +64,48 @@ func (m Model) titledPanelH(title, content, footer string, width, height, scroll
} }
visible := contentLines[scrollOffset:end] visible := contentLines[scrollOffset:end]
borderLine := func(line string) string { showScrollbar := totalItems > 0 && totalItems > bodyH
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│") var thumbStart, thumbEnd int
if showScrollbar {
thumbSize := bodyH * bodyH / totalItems
if thumbSize < 1 {
thumbSize = 1
}
scrollRange := totalItems - bodyH
if scrollRange < 1 {
scrollRange = 1
}
trackSpace := bodyH - thumbSize
thumbStart = scrollOffset * trackSpace / scrollRange
thumbEnd = thumbStart + thumbSize
}
scrollTrack := bc.Render("│")
scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Muted).Render("┃")
borderLine := func(line string, idx int) string {
rightBorder := bc.Render("│")
if showScrollbar && idx >= thumbStart && idx < thumbEnd {
rightBorder = scrollThumb
} else if showScrollbar {
rightBorder = scrollTrack
}
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + rightBorder
}
emptyLine := func(idx int) string {
return borderLine(strings.Repeat(" ", innerW), idx)
} }
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string var lines []string
lines = append(lines, top) lines = append(lines, top)
for _, line := range visible { for i, line := range visible {
lines = append(lines, borderLine(line)) lines = append(lines, borderLine(line, i))
} }
for len(lines) < height-1-len(footerLines) { for i := len(visible); len(lines) < height-1-len(footerLines); i++ {
lines = append(lines, emptyLine) lines = append(lines, emptyLine(i))
} }
for _, line := range footerLines { for _, line := range footerLines {
lines = append(lines, borderLine(line)) lines = append(lines, borderLine(line, -1))
} }
lines = append(lines, bottom) lines = append(lines, bottom)
+11 -1
View File
@@ -243,6 +243,16 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
detailPref, _ := s.GetPreference(ctx, "detail_open") detailPref, _ := s.GetPreference(ctx, "detail_open")
bp := bottomLogs
if bpPref, _ := s.GetPreference(ctx, "bottom_panel"); bpPref != "" {
switch bpPref {
case "none":
bp = bottomNone
case "maint":
bp = bottomMaint
}
}
return Model{ return Model{
ctx: ctx, ctx: ctx,
state: stateDashboard, state: stateDashboard,
@@ -257,7 +267,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
bottomPanel: bottomLogs, bottomPanel: bp,
detailOpen: detailPref == "true", detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
+2
View File
@@ -662,6 +662,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focusedPanel = panelMaint m.focusedPanel = panelMaint
} }
m.recalcLayout() m.recalcLayout()
return m, m.saveBottomPanelPref()
case "l": case "l":
if m.bottomPanel == bottomLogs { if m.bottomPanel == bottomLogs {
m.bottomPanel = bottomNone m.bottomPanel = bottomNone
@@ -671,6 +672,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focusedPanel = panelLogs m.focusedPanel = panelLogs
} }
m.recalcLayout() m.recalcLayout()
return m, m.saveBottomPanelPref()
case "up", "k": case "up", "k":
if m.focusedPanel == panelDetail && m.detailOpen { if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset-- m.detailScrollOffset--
+2 -2
View File
@@ -163,7 +163,7 @@ func (m Model) viewMonitorsLayout() string {
monTargetH := m.maxTableRows + 5 monTargetH := m.maxTableRows + 5
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors)) monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, len(m.sites), m.focusedPanel == panelMonitors))
var topParts []string var topParts []string
topParts = append(topParts, monPanel) topParts = append(topParts, monPanel)
@@ -181,7 +181,7 @@ func (m Model) viewMonitorsLayout() string {
monHeight := lipgloss.Height(monPanel) monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight) detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2) footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, 0, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel) topParts = append(topParts, detailPanel)
} }
+1 -1
View File
@@ -75,7 +75,7 @@ func (m Model) viewDetailFullscreen() string {
} }
return lipgloss.NewStyle().Padding(1, 2).Render( return lipgloss.NewStyle().Padding(1, 2).Render(
m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, true)) m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, 0, true))
} }
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string { func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {