Compare commits

..

2 Commits

Author SHA1 Message Date
lerko ce3cfad7f8 fix(tui): sort groups by active sort column instead of fixed ID order
CI / test (pull_request) Successful in 2m9s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Failing after 56s
2026-07-21 20:41:21 -04:00
lerko d6e6011b35 feat(tui): add vim-style navigation (gg, G, ctrl-u, ctrl-d)
All scrollable panels now support gg (top), G (bottom), ctrl-u (half
page up), and ctrl-d (half page down). Works across dashboard panels
(monitors, detail, maint, logs), fullscreen logs, fullscreen detail,
and settings. Uses a pendingG flag for the two-key gg sequence.
2026-07-21 19:37:26 -04:00
3 changed files with 217 additions and 2 deletions
+113 -2
View File
@@ -77,8 +77,6 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol
ungrouped = append(ungrouped, s)
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
sortSlice := func(s []models.Site) {
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
sort.SliceStable(s, func(i, j int) bool {
@@ -98,6 +96,7 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol
})
}
sortSlice(groups)
for pid := range children {
c := children[pid]
sortSlice(c)
@@ -169,6 +168,118 @@ func (m *Model) clampCursor() {
}
}
func (m *Model) detailCmdIfNeeded() tea.Cmd {
if m.focusedPanel == panelMonitors && m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault
m.detailScrollOffset = 0
return m.loadDetailCmd(m.sites[m.cursor].ID)
}
return nil
}
func (m *Model) jumpToTop() {
switch m.focusedPanel {
case panelDetail:
m.detailScrollOffset = 0
case panelMaint:
m.maintCursor = 0
case panelLogs:
m.logScrollOffset = 0
default:
m.cursor = 0
m.tableOffset = 0
m.syncSelectedID()
}
}
func (m *Model) jumpToBottom() {
switch m.focusedPanel {
case panelDetail:
m.detailScrollOffset = 9999
case panelMaint:
windows := m.activeMaintWindows()
if len(windows) > 0 {
m.maintCursor = len(windows) - 1
}
case panelLogs:
total := m.filteredLogCount()
if total > 0 {
m.logScrollOffset = total - 1
}
default:
max := m.currentListLen() - 1
if max >= 0 {
m.cursor = max
if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset = m.cursor - m.maxTableRows + 1
}
m.syncSelectedID()
}
}
}
func (m *Model) halfPageUp() {
half := m.maxTableRows / 2
if half < 1 {
half = 1
}
switch m.focusedPanel {
case panelDetail:
m.detailScrollOffset -= half
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case panelMaint:
m.maintCursor -= half
if m.maintCursor < 0 {
m.maintCursor = 0
}
case panelLogs:
m.scrollLogs(-half)
default:
m.cursor -= half
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
m.syncSelectedID()
}
}
func (m *Model) halfPageDown() {
half := m.maxTableRows / 2
if half < 1 {
half = 1
}
switch m.focusedPanel {
case panelDetail:
m.detailScrollOffset += half
case panelMaint:
windows := m.activeMaintWindows()
m.maintCursor += half
if len(windows) > 0 && m.maintCursor >= len(windows) {
m.maintCursor = len(windows) - 1
}
case panelLogs:
m.scrollLogs(half)
default:
max := m.currentListLen() - 1
m.cursor += half
if m.cursor > max {
m.cursor = max
}
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset = m.cursor - m.maxTableRows + 1
}
m.syncSelectedID()
}
}
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
// UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
// handleTabData can drop out-of-order results from slower earlier loads. The
+2
View File
@@ -218,6 +218,8 @@ type Model struct {
filterMode bool
filterText string
pendingG bool
// demoMode renders a stable status dot instead of the animated pulse so
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
demoMode bool
+102
View File
@@ -252,6 +252,17 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "g" {
if m.pendingG {
m.pendingG = false
m.logViewport.GotoTop()
return m, nil
}
m.pendingG = true
return m, nil
}
m.pendingG = false
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
@@ -261,6 +272,12 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
case "f":
m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
case "G":
m.logViewport.GotoBottom()
case "ctrl+u":
m.logViewport.ScrollUp(m.logViewport.Height / 2)
case "ctrl+d":
m.logViewport.ScrollDown(m.logViewport.Height / 2)
case "up", "k":
m.logViewport.ScrollUp(1)
case "down", "j":
@@ -286,6 +303,17 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "g" {
if m.pendingG {
m.pendingG = false
m.detailScrollOffset = 0
return m, nil
}
m.pendingG = true
return m, nil
}
m.pendingG = false
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
@@ -323,6 +351,15 @@ func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.loadSLACmd(m.slaSiteID, idx)
}
}
case "G":
m.detailScrollOffset = 9999
case "ctrl+u":
m.detailScrollOffset -= 5
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case "ctrl+d":
m.detailScrollOffset += 5
case "up", "k":
m.detailScrollOffset--
if m.detailScrollOffset < 0 {
@@ -544,7 +581,52 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "g" {
if m.pendingG {
m.pendingG = false
m.settingsCursor = 0
m.settingsOffset = 0
return m, nil
}
m.pendingG = true
return m, nil
}
m.pendingG = false
switch msg.String() {
case "G":
max := m.settingsListLen() - 1
if max >= 0 {
m.settingsCursor = max
if m.settingsCursor >= m.settingsOffset+m.maxTableRows {
m.settingsOffset = m.settingsCursor - m.maxTableRows + 1
}
}
case "ctrl+u":
half := m.maxTableRows / 2
if half < 1 {
half = 1
}
m.settingsCursor -= half
if m.settingsCursor < 0 {
m.settingsCursor = 0
}
if m.settingsCursor < m.settingsOffset {
m.settingsOffset = m.settingsCursor
}
case "ctrl+d":
half := m.maxTableRows / 2
if half < 1 {
half = 1
}
max := m.settingsListLen() - 1
m.settingsCursor += half
if m.settingsCursor > max {
m.settingsCursor = max
}
if m.settingsCursor >= m.settingsOffset+m.maxTableRows {
m.settingsOffset = m.settingsCursor - m.maxTableRows + 1
}
case "esc", "S":
m.state = stateDashboard
case "ctrl+c":
@@ -635,7 +717,27 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "g" {
if m.pendingG {
m.pendingG = false
m.jumpToTop()
return m, m.detailCmdIfNeeded()
}
m.pendingG = true
return m, nil
}
m.pendingG = false
switch msg.String() {
case "G":
m.jumpToBottom()
return m, m.detailCmdIfNeeded()
case "ctrl+u":
m.halfPageUp()
return m, m.detailCmdIfNeeded()
case "ctrl+d":
m.halfPageDown()
return m, m.detailCmdIfNeeded()
case "q":
return m, tea.Quit
case "/":