feat(tui): panel focus with click, scroll, and keyboard
Click any panel (Monitors, Logs, Detail) to focus it — accent border follows focus. Mouse wheel scrolls the focused panel. Keyboard: l toggles log panel focus. Arrow keys scroll logs when focused, navigate monitors when not. Esc returns focus to monitors. Log sidebar now supports scroll offset — tracks position across renders without a viewport. Mouse wheel scrolls 3 lines, keyboard scrolls 1.
This commit is contained in:
@@ -60,7 +60,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
|
|||||||
|
|
||||||
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||||
|
|
||||||
var lines []string
|
var all []string
|
||||||
for _, line := range logs {
|
for _, line := range logs {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
continue
|
continue
|
||||||
@@ -68,11 +68,43 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
|
|||||||
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
|
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, m.renderCompactLogLine(line, width))
|
all = append(all, m.renderCompactLogLine(line, width))
|
||||||
if maxLines > 0 && len(lines) >= maxLines {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sidebarStyle.Render(strings.Join(lines, "\n"))
|
start := m.logScrollOffset
|
||||||
|
if start > len(all) {
|
||||||
|
start = len(all)
|
||||||
|
}
|
||||||
|
end := start + maxLines
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
visible := all[start:end]
|
||||||
|
|
||||||
|
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollLogs(delta int) {
|
||||||
|
logs := m.engine.GetLogs()
|
||||||
|
total := 0
|
||||||
|
for _, line := range logs {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logScrollOffset += delta
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
|
if m.logScrollOffset > total-1 {
|
||||||
|
m.logScrollOffset = total - 1
|
||||||
|
}
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ const (
|
|||||||
sectionUsers = 2
|
sectionUsers = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
panelMonitors = 0
|
||||||
|
panelLogs = 1
|
||||||
|
panelDetail = 2
|
||||||
|
)
|
||||||
|
|
||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -124,6 +130,8 @@ type Model struct {
|
|||||||
termWidth int
|
termWidth int
|
||||||
termHeight int
|
termHeight int
|
||||||
contentWidth int
|
contentWidth int
|
||||||
|
focusedPanel int
|
||||||
|
logScrollOffset int
|
||||||
editID int
|
editID int
|
||||||
editToken string
|
editToken string
|
||||||
|
|
||||||
|
|||||||
+54
-4
@@ -274,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
m.scrollLogs(-3)
|
||||||
|
} else {
|
||||||
|
m.scrollLogs(3)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
listLen := m.currentListLen()
|
listLen := m.currentListLen()
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
@@ -291,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,11 +538,26 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.currentTab == tabSettings {
|
if m.currentTab == tabSettings {
|
||||||
m.switchSettingsSection(m.settingsSection - 1)
|
m.switchSettingsSection(m.settingsSection - 1)
|
||||||
}
|
}
|
||||||
case "right", "l":
|
case "right":
|
||||||
if m.currentTab == tabSettings {
|
if m.currentTab == tabSettings {
|
||||||
m.switchSettingsSection(m.settingsSection + 1)
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
}
|
}
|
||||||
|
case "l":
|
||||||
|
switch m.currentTab {
|
||||||
|
case tabSettings:
|
||||||
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
|
case tabMonitors:
|
||||||
|
if m.focusedPanel == panelLogs {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
}
|
||||||
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
m.scrollLogs(-1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
if m.cursor < m.tableOffset {
|
if m.cursor < m.tableOffset {
|
||||||
@@ -542,6 +569,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
m.scrollLogs(1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
max := m.currentListLen() - 1
|
max := m.currentListLen() - 1
|
||||||
if m.cursor < max {
|
if m.cursor < max {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
@@ -600,9 +631,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateAlertDetail
|
m.state = stateAlertDetail
|
||||||
}
|
}
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.currentTab == tabMonitors && m.detailOpen {
|
if m.currentTab == tabMonitors {
|
||||||
m.detailOpen = false
|
if m.focusedPanel != panelMonitors {
|
||||||
m.recalcLayout()
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.detailOpen {
|
||||||
|
m.detailOpen = false
|
||||||
|
m.recalcLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "h":
|
case "h":
|
||||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
@@ -750,6 +785,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabMonitors {
|
||||||
|
if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
return m, nil
|
||||||
|
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelDetail
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefix, listLen := m.currentZonePrefix()
|
prefix, listLen := m.currentZonePrefix()
|
||||||
end := m.tableOffset + m.maxTableRows
|
end := m.tableOffset + m.maxTableRows
|
||||||
if end > listLen {
|
if end > listLen {
|
||||||
@@ -759,6 +806,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,9 +159,9 @@ func (m Model) viewDashboard() string {
|
|||||||
rightW := availW - leftW
|
rightW := availW - leftW
|
||||||
m.contentWidth = leftW - 2
|
m.contentWidth = leftW - 2
|
||||||
monitors := m.viewSitesTab()
|
monitors := m.viewSitesTab()
|
||||||
monPanel := m.titledPanel("Monitors", monitors, leftW, !m.detailOpen)
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
||||||
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
||||||
logPanel := m.titledPanel("Logs", sidebarContent, rightW, false)
|
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
||||||
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
||||||
if m.detailOpen {
|
if m.detailOpen {
|
||||||
site := ""
|
site := ""
|
||||||
@@ -169,7 +169,7 @@ func (m Model) viewDashboard() string {
|
|||||||
site = m.sites[m.cursor].Name
|
site = m.sites[m.cursor].Name
|
||||||
}
|
}
|
||||||
detail := m.viewDetailInline(availW - 2)
|
detail := m.viewDetailInline(availW - 2)
|
||||||
detailPanel := m.titledPanel(site, detail, availW, true)
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
content = top + "\n" + detailPanel
|
content = top + "\n" + detailPanel
|
||||||
} else {
|
} else {
|
||||||
content = top
|
content = top
|
||||||
@@ -178,14 +178,14 @@ func (m Model) viewDashboard() string {
|
|||||||
m.contentWidth = m.termWidth - 2
|
m.contentWidth = m.termWidth - 2
|
||||||
monitors := m.viewSitesTab()
|
monitors := m.viewSitesTab()
|
||||||
availW := m.termWidth - chromePadH
|
availW := m.termWidth - chromePadH
|
||||||
monPanel := m.titledPanel("Monitors", monitors, availW, !m.detailOpen)
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
||||||
if m.detailOpen {
|
if m.detailOpen {
|
||||||
site := ""
|
site := ""
|
||||||
if m.cursor < len(m.sites) {
|
if m.cursor < len(m.sites) {
|
||||||
site = m.sites[m.cursor].Name
|
site = m.sites[m.cursor].Name
|
||||||
}
|
}
|
||||||
detail := m.viewDetailInline(availW - 2)
|
detail := m.viewDetailInline(availW - 2)
|
||||||
detailPanel := m.titledPanel(site, detail, availW, true)
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
content = monPanel + "\n" + detailPanel
|
content = monPanel + "\n" + detailPanel
|
||||||
} else {
|
} else {
|
||||||
content = monPanel
|
content = monPanel
|
||||||
@@ -303,10 +303,12 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
|||||||
var keys string
|
var keys string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case tabMonitors:
|
case tabMonitors:
|
||||||
if m.detailOpen {
|
if m.focusedPanel == panelLogs {
|
||||||
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [↑/↓]Select [T]Theme [q]Quit"
|
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
|
||||||
|
} else if m.detailOpen {
|
||||||
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
||||||
} else {
|
} else {
|
||||||
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
||||||
}
|
}
|
||||||
case tabMaint:
|
case tabMaint:
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
|||||||
Reference in New Issue
Block a user