summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-12 23:05:32 +0300
committerPaul Buetow <paul@buetow.org>2026-05-12 23:05:32 +0300
commit15338e6d4253fc8f4871a68ddcc41c6e3ce58220 (patch)
tree1bc84a277b7e45443658a5aad97d21f79359598e
parent9f8096551ecf7184693b786a8e0b77d290086eac (diff)
extract dashboard tab framework into Tab registry for OCP compliance
Introduce tabDescriptor struct and tabDescriptors map in new tabregistry.go. Each tab registers its name, short name, ordered position, allowed viz modes, render function, scroll handler, and optional init tick command. Adding a new tab now requires only a single registry entry — no existing switch/if chains need editing. Key changes: - Tab.String() and tabLabel() use lookupTab() instead of a switch - renderActiveTabContent() dispatches via d.Render (replaces renderActiveTab switch) - handleScrollKey() dispatches via d.HandleScroll (replaces tab switch) - Init() and postKeyTransitionCmd() use d.InitCmd (replaces stream/flame tab checks) - allowedVizModes() delegates to tabAllowedVizModes() from registry - orderedTabs() replaces hardcoded allTabs slice, derived from Position field - bubbleChartFor() helper eliminates 5 repeated switch-on-tab for chart ops - toggleBubbleMetric, tickActiveBubbleChart, moveBubbleSelection, activeBubbleChartHasNodes all use bubbleChartFor() - refreshBubbleData split into two focused functions under 50 lines - Two pre-existing test functions over 50 lines refactored All tests pass; go build ./internal/tui/... clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/tui/dashboard/model.go350
-rw-r--r--internal/tui/dashboard/model_test.go75
-rw-r--r--internal/tui/dashboard/tabregistry.go257
-rw-r--r--internal/tui/dashboard/tabs.go86
4 files changed, 477 insertions, 291 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index c759037..123600a 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -147,18 +147,16 @@ func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source,
return m
}
-// Init starts periodic refresh ticks.
+// Init starts periodic refresh ticks. The tab registry's InitCmd field is
+// consulted to start any additional high-frequency tick the active tab needs
+// (e.g. stream and flame use 200 ms cadence ticks beyond the base 1 s tick).
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{tickCmd(m.refreshEvery)}
- switch m.activeTab {
- case TabStream:
- cmds = append(cmds, streamTickCmd())
- case TabFlame:
- cmds = append(cmds, flameTickCmd())
- default:
- if m.bubbleEnabledForTab(m.activeTab) {
- cmds = append(cmds, bubbleTickCmdFn())
- }
+ d := lookupTab(m.activeTab)
+ if d.InitCmd != nil {
+ cmds = append(cmds, d.InitCmd())
+ } else if m.bubbleEnabledForTab(m.activeTab) {
+ cmds = append(cmds, bubbleTickCmdFn())
}
if cmd := m.autoResetTickCmd(); cmd != nil {
cmds = append(cmds, cmd)
@@ -758,17 +756,19 @@ func processSelectionLabel(proc statsengine.ProcessSnapshot) string {
return label
}
+// postKeyTransitionCmd assembles the commands needed when the active tab
+// changes after a key press. Each tab's InitCmd is started when we first
+// enter that tab so high-frequency ticks (stream, flame) resume correctly.
func (m Model) postKeyTransitionCmd(prevActiveTab Tab, cmd tea.Cmd) tea.Cmd {
cmds := make([]tea.Cmd, 0, 4)
cmds = append(cmds, cmd)
- if prevActiveTab != TabStream && m.activeTab == TabStream {
- cmds = append(cmds, streamTickCmd())
- }
- if prevActiveTab != TabFlame && m.activeTab == TabFlame {
- cmds = append(cmds, flameTickCmd())
- }
- if prevActiveTab != m.activeTab && m.bubbleEnabledForTab(m.activeTab) {
- cmds = append(cmds, bubbleTickCmdFn())
+ if prevActiveTab != m.activeTab {
+ d := lookupTab(m.activeTab)
+ if d.InitCmd != nil {
+ cmds = append(cmds, d.InitCmd())
+ } else if m.bubbleEnabledForTab(m.activeTab) {
+ cmds = append(cmds, bubbleTickCmdFn())
+ }
}
return batchCmds(cmds...)
}
@@ -795,52 +795,27 @@ func batchCmds(cmds ...tea.Cmd) tea.Cmd {
}
}
+// handleScrollKey dispatches navigation key presses to the active tab's
+// registered scroll handler. Bubble-chart scroll is handled first since it
+// applies regardless of which tab-specific handler is registered.
func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
- keyStr := msg.String()
if m.bubbleEnabledForTab(m.activeTab) {
- switch keyStr {
- case "down", "j", "right", "l":
- return m.moveBubbleSelection(1), nil
- case "up", "k", "left", "h":
- return m.moveBubbleSelection(-1), nil
- default:
- return false, nil
- }
+ return m.handleBubbleScrollKey(msg)
}
- switch m.activeTab {
- case TabSyscalls:
- if m.syscallsVizMode == tabVizModeTreemap {
- return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil
- }
- return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol, m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
- case TabFiles:
- if m.filesDirGrouped {
- return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol, m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
- }
- return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol, m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
- case TabProcesses:
- return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol, m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil
- case TabStream:
- streamWidth, streamHeight := streamViewport(m.width, m.height)
- m.streamModel.SetViewport(streamWidth, streamHeight)
- handled := m.streamModel.HandleTeaKey(msg)
- if m.streamModel.ConsumeGlobalFilterUndoRequest() {
- return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} }
- }
- if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok {
- return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
- }
- if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok {
- editorCmd, err := eventstream.EditorCommandForPath(path)
- if err != nil {
- m.streamModel.SetStatusMessage("Open failed: " + err.Error())
- return true, nil
- }
- return true, tea.ExecProcess(editorCmd, func(err error) tea.Msg {
- return streamEditorDoneMsg{err: err}
- })
- }
- return handled, nil
+ d := lookupTab(m.activeTab)
+ if d.HandleScroll == nil {
+ return false, nil
+ }
+ return d.HandleScroll(m, msg)
+}
+
+// handleBubbleScrollKey handles directional keys when a bubble chart is active.
+func (m *Model) handleBubbleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ switch msg.String() {
+ case "down", "j", "right", "l":
+ return m.moveBubbleSelection(1), nil
+ case "up", "k", "left", "h":
+ return m.moveBubbleSelection(-1), nil
default:
return false, nil
}
@@ -1197,13 +1172,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
if s, ok := m.renderActiveContentTable(width, activeHeight); ok {
return s
}
- return renderActiveTab(
- m.activeTab, m.latest, streamModel, flameModel,
- width, activeHeight, m.pidFilter,
- m.syscallsOffset, m.syscallsCol,
- m.filesOffset, m.filesCol,
- m.filesDirGrouped, m.filesDirOffset, m.filesDirCol,
- m.processesOffset, m.processesCol,
+ return renderActiveTabContent(
+ &m, m.activeTab, m.latest, streamModel, flameModel,
+ width, activeHeight,
)
}
@@ -1264,85 +1235,92 @@ func (m *Model) setBubbleViewports(width, height int) {
m.processesChart.SetViewport(width, height)
}
+// refreshBubbleData pushes the latest snapshot data into all three bubble
+// charts and returns whether the currently active tab's chart is still
+// animating. This drives the bubble tick loop.
func (m *Model) refreshBubbleData() bool {
flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp)
m.setBubbleViewports(flameWidth, flameHeight)
syscallsAnimating := m.syscallsChart.SetData(syscallBubbleData(m.latest))
-
- if m.filesDirGrouped {
- m.filesChart.SetStatusHint("")
- } else {
- m.filesChart.SetStatusHint("Files bubble view requires directory mode (press d).")
- }
- filesAnimating := false
- if m.filesDirGrouped {
- filesAnimating = m.filesChart.SetData(filesDirBubbleData(m.latest))
- } else {
- m.filesChart.SetData(nil)
- }
+ filesAnimating := m.refreshFilesBubbleData()
processesAnimating := m.processesChart.SetData(processBubbleData(m.latest))
+ // Return whether the active tab's chart is animating; the caller uses this
+ // to decide whether to schedule another bubble tick.
+ if !m.bubbleEnabledForTab(m.activeTab) {
+ return false
+ }
switch m.activeTab {
case TabSyscalls:
- return m.syscallsVizMode == tabVizModeBubbles && syscallsAnimating
+ return syscallsAnimating
case TabFiles:
- return m.filesVizMode == tabVizModeBubbles && filesAnimating
+ return filesAnimating
case TabProcesses:
- return m.processesVizMode == tabVizModeBubbles && processesAnimating
+ return processesAnimating
default:
return false
}
}
-func (m *Model) tickActiveBubbleChart() bool {
- switch m.activeTab {
+// refreshFilesBubbleData updates the files bubble chart. When not in
+// dir-grouped mode the chart is cleared with a status hint explaining why.
+func (m *Model) refreshFilesBubbleData() bool {
+ if m.filesDirGrouped {
+ m.filesChart.SetStatusHint("")
+ return m.filesChart.SetData(filesDirBubbleData(m.latest))
+ }
+ m.filesChart.SetStatusHint("Files bubble view requires directory mode (press d).")
+ m.filesChart.SetData(nil)
+ return false
+}
+
+// bubbleChartFor returns a pointer to the bubble chart for the given tab, or
+// nil when that tab has no bubble chart. This eliminates repeated switch
+// statements over tab identity for chart operations.
+func (m *Model) bubbleChartFor(tab Tab) *bubbleChart {
+ switch tab {
case TabSyscalls:
- if m.syscallsVizMode != tabVizModeBubbles {
- return false
- }
- return m.syscallsChart.Tick(0)
+ return &m.syscallsChart
case TabFiles:
- if m.filesVizMode != tabVizModeBubbles {
- return false
- }
- return m.filesChart.Tick(0)
+ return &m.filesChart
case TabProcesses:
- if m.processesVizMode != tabVizModeBubbles {
- return false
- }
- return m.processesChart.Tick(0)
+ return &m.processesChart
default:
- return false
+ return nil
}
}
-func (m *Model) moveBubbleSelection(delta int) bool {
- switch m.activeTab {
+// tabVizModeFor returns the current visualization mode for tab. Only the three
+// bubble-capable tabs (syscalls, files, processes) carry per-tab mode state;
+// all other tabs implicitly use tabVizModeTable.
+func (m Model) tabVizModeFor(tab Tab) tabVizMode {
+ switch tab {
case TabSyscalls:
- return m.syscallsChart.MoveSelection(delta)
+ return m.syscallsVizMode
case TabFiles:
- return m.filesChart.MoveSelection(delta)
+ return m.filesVizMode
case TabProcesses:
- return m.processesChart.MoveSelection(delta)
+ return m.processesVizMode
default:
- return false
+ return tabVizModeTable
}
}
-func (m Model) activeBubbleChartHasNodes() bool {
- switch m.activeTab {
+// setTabVizMode updates the stored viz mode for tab.
+func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) {
+ switch tab {
case TabSyscalls:
- return m.syscallsChart.HasNodes()
+ m.syscallsVizMode = mode
case TabFiles:
- return m.filesChart.HasNodes()
+ m.filesVizMode = mode
case TabProcesses:
- return m.processesChart.HasNodes()
- default:
- return false
+ m.processesVizMode = mode
}
}
+// bubbleEnabledForTab reports whether the bubble chart is the active view for
+// tab. The Files tab additionally requires dir-grouped mode to be on.
func (m Model) bubbleEnabledForTab(tab Tab) bool {
switch tab {
case TabSyscalls:
@@ -1356,6 +1334,39 @@ func (m Model) bubbleEnabledForTab(tab Tab) bool {
}
}
+// tickActiveBubbleChart advances the animation frame for the active tab's
+// bubble chart. Returns false when bubbles are not active for the current tab.
+func (m *Model) tickActiveBubbleChart() bool {
+ if !m.bubbleEnabledForTab(m.activeTab) {
+ return false
+ }
+ ch := m.bubbleChartFor(m.activeTab)
+ if ch == nil {
+ return false
+ }
+ return ch.Tick(0)
+}
+
+// moveBubbleSelection shifts the bubble selection by delta for the active tab.
+func (m *Model) moveBubbleSelection(delta int) bool {
+ ch := m.bubbleChartFor(m.activeTab)
+ if ch == nil {
+ return false
+ }
+ return ch.MoveSelection(delta)
+}
+
+// activeBubbleChartHasNodes reports whether the active tab's bubble chart
+// has any nodes to display.
+func (m Model) activeBubbleChartHasNodes() bool {
+ mutable := m
+ ch := mutable.bubbleChartFor(m.activeTab)
+ if ch == nil {
+ return false
+ }
+ return ch.HasNodes()
+}
+
func (m *Model) cycleVisualizationMode() tea.Cmd {
allowed := m.allowedVizModes(m.activeTab)
if len(allowed) < 2 {
@@ -1374,71 +1385,29 @@ func (m *Model) cycleVisualizationMode() tea.Cmd {
return nil
}
+// toggleBubbleMetric cycles the bubble metric for the active tab's chart.
+// The Files tab additionally requires dir-grouped mode to accept metric changes.
func (m *Model) toggleBubbleMetric() tea.Cmd {
- switch m.activeTab {
- case TabSyscalls:
- m.syscallsChart.SetMetric(nextBubbleMetric(m.syscallsChart.Metric()))
- m.refreshBubbleData()
- if m.syscallsVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
- return bubbleTickCmdFn()
- }
- case TabFiles:
- if !m.filesDirGrouped {
- return nil
- }
- m.filesChart.SetMetric(nextBubbleMetric(m.filesChart.Metric()))
- m.refreshBubbleData()
- if m.filesVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
- return bubbleTickCmdFn()
- }
- case TabProcesses:
- m.processesChart.SetMetric(nextBubbleMetric(m.processesChart.Metric()))
- m.refreshBubbleData()
- if m.processesVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
- return bubbleTickCmdFn()
- }
+ if m.activeTab == TabFiles && !m.filesDirGrouped {
+ return nil
}
- return nil
-}
-
-func (m Model) tabVizModeFor(tab Tab) tabVizMode {
- switch tab {
- case TabSyscalls:
- return m.syscallsVizMode
- case TabFiles:
- return m.filesVizMode
- case TabProcesses:
- return m.processesVizMode
- default:
- return tabVizModeTable
+ ch := m.bubbleChartFor(m.activeTab)
+ if ch == nil {
+ return nil
}
-}
-
-func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) {
- switch tab {
- case TabSyscalls:
- m.syscallsVizMode = mode
- case TabFiles:
- m.filesVizMode = mode
- case TabProcesses:
- m.processesVizMode = mode
+ ch.SetMetric(nextBubbleMetric(ch.Metric()))
+ m.refreshBubbleData()
+ if m.bubbleEnabledForTab(m.activeTab) && m.activeBubbleChartHasNodes() {
+ return bubbleTickCmdFn()
}
+ return nil
}
+// allowedVizModes returns the visualization modes available for tab. It
+// delegates to the tab registry so that new tabs need no changes here;
+// only the Files tab has a runtime condition (requires dir-grouped mode).
func (m Model) allowedVizModes(tab Tab) []tabVizMode {
- switch tab {
- case TabSyscalls:
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
- case TabProcesses:
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
- case TabFiles:
- if m.filesDirGrouped {
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap, tabVizModeIcicle}
- }
- return []tabVizMode{tabVizModeTable}
- default:
- return []tabVizMode{tabVizModeTable}
- }
+ return tabAllowedVizModes(tab, m.filesDirGrouped)
}
func nextVizMode(current tabVizMode, allowed []tabVizMode) tabVizMode {
@@ -1469,41 +1438,20 @@ func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} })
}
-func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, syscallsCol, filesOffset, filesCol int, filesDirGrouped bool, filesDirOffset, filesDirCol, processesOffset, processesCol int) string {
- if tab == TabStream {
- if streamModel == nil {
- return common.PanelStyle.Render("Stream: waiting for source...")
- }
- return streamModel.View(width, height)
- }
- if tab == TabFlame {
- if flameModel == nil {
- return common.PanelStyle.Render("Flame: waiting for model...")
- }
- return flameModel.View().Content
+// renderActiveTabContent dispatches to the registered render function for tab.
+// It handles the common waiting-for-stats guard for snapshot-dependent tabs, so
+// individual render functions can assume snap is non-nil (stream and flame
+// tabs receive snap=nil and handle the absent-model case themselves).
+func renderActiveTabContent(m *Model, tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height int) string {
+ d := lookupTab(tab)
+ if d.Render == nil {
+ return common.PanelStyle.Render("Unknown tab")
}
-
- if snap == nil {
+ // Stream and flame manage their own "waiting" state; all others need a snapshot.
+ if tab != TabStream && tab != TabFlame && snap == nil {
return common.PanelStyle.Render(tab.String() + ": waiting for stats...")
}
-
- switch tab {
- case TabOverview:
- return renderOverview(snap, width, height)
- case TabSyscalls:
- return renderSyscallsWithOffset(snap, width, height, syscallsOffset, syscallsCol)
- case TabFiles:
- if filesDirGrouped {
- return renderFilesDirGrouped(snap, width, height, filesDirOffset, filesDirCol)
- }
- return renderFilesWithOffset(snap, width, height, filesOffset, filesCol)
- case TabProcesses:
- return renderProcessesWithOffset(snap, width, height, processesOffset, processesCol, pidFilter)
- case TabLatency:
- return renderLatencyGapsTab(snap, width, height)
- default:
- return common.PanelStyle.Render("Unknown tab")
- }
+ return d.Render(m, snap, streamModel, flameModel, width, height)
}
func streamTickCmd() tea.Cmd {
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index a6c4455..4ca10c9 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -946,7 +946,10 @@ func TestPausedFlameDashboardViewPreservesZoomedSelectedLine(t *testing.T) {
}
}
-func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
+// newPausedStreamModel creates a stream tab model with 300 events, sized at
+// 120x30, and already paused — ready for scroll key assertions.
+func newPausedStreamModel(t *testing.T) Model {
+ t.Helper()
rb := eventstream.NewRingBuffer()
for i := 0; i < 300; i++ {
rb.Push(eventstream.StreamEvent{
@@ -958,21 +961,22 @@ func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
FileName: fmt.Sprintf("/tmp/file-%03d", i),
})
}
-
m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap())
m.activeTab = TabStream
m.showHelp = true
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m = next.(Model)
-
m.streamModel.Refresh()
_ = m.View()
-
next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) // pause
- m = next.(Model)
+ return next.(Model)
+}
+
+func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
+ m := newPausedStreamModel(t)
before := rowFromStreamView(t, m.View().Content)
- next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})})
m = next.(Model)
afterK := rowFromStreamView(t, m.View().Content)
if afterK >= before {
@@ -1093,6 +1097,12 @@ func TestMetricToggleAppliesInFilesTreemapMode(t *testing.T) {
}
}
+// pressKey sends a single rune key to model and returns the updated model.
+func pressKey(m Model, r rune) Model {
+ next, _ := m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
+ return next.(Model)
+}
+
func TestFilesVisualizationRequiresDirectoryMode(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
{Path: "/tmp/a", Accesses: 3},
@@ -1102,45 +1112,43 @@ func TestFilesVisualizationRequiresDirectoryMode(t *testing.T) {
m.activeTab = TabFiles
m.latest = &snap
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model := next.(Model)
- if got := model.filesVizMode; got != tabVizModeTable {
+ // v should not cycle viz mode when directory mode is off.
+ m = pressKey(m, 'v')
+ if got := m.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files treemap mode to stay disabled without directory mode")
}
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
- model = next.(Model)
- if !model.filesDirGrouped {
+ // Enable directory mode; cycling should now work.
+ m = pressKey(m, 'd')
+ if !m.filesDirGrouped {
t.Fatalf("expected files dir mode enabled")
}
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeBubbles {
+ assertFilesVizCycle(t, m)
+}
+
+// assertFilesVizCycle verifies the full table→bubbles→treemap→icicle→table
+// cycle when directory mode is on, and that leaving dir mode resets to table.
+func assertFilesVizCycle(t *testing.T, m Model) {
+ t.Helper()
+ m = pressKey(m, 'v')
+ if got := m.filesVizMode; got != tabVizModeBubbles {
t.Fatalf("expected files bubbles mode enabled in directory mode")
}
-
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeTreemap {
+ m = pressKey(m, 'v')
+ if got := m.filesVizMode; got != tabVizModeTreemap {
t.Fatalf("expected files treemap mode enabled in directory mode")
}
-
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeIcicle {
+ m = pressKey(m, 'v')
+ if got := m.filesVizMode; got != tabVizModeIcicle {
t.Fatalf("expected files icicle mode enabled in directory mode")
}
-
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeTable {
+ m = pressKey(m, 'v')
+ if got := m.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files mode cycled back to table")
}
-
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
- model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeTable {
+ m = pressKey(m, 'd') // leave dir mode
+ if got := m.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files mode reset to table when leaving directory mode")
}
}
@@ -1516,7 +1524,10 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) {
statsengine.HistogramSnapshot{},
statsengine.HistogramSnapshot{},
)
- out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, 0, 0, true, 0, 0, 0, 0)
+ // Build a minimal model with dir-grouped mode enabled so the registry
+ // render function routes to the directory view.
+ m := Model{filesDirGrouped: true, pidFilter: -1}
+ out := renderActiveTabContent(&m, TabFiles, &snap, nil, nil, 120, 30)
if !strings.Contains(out, "Directory") {
t.Fatalf("expected grouped directory files view header, got %q", out)
}
diff --git a/internal/tui/dashboard/tabregistry.go b/internal/tui/dashboard/tabregistry.go
new file mode 100644
index 0000000..d16a363
--- /dev/null
+++ b/internal/tui/dashboard/tabregistry.go
@@ -0,0 +1,257 @@
+package dashboard
+
+import (
+ "sort"
+
+ "ior/internal/statsengine"
+ common "ior/internal/tui/common"
+ "ior/internal/tui/eventstream"
+ flamegraphtui "ior/internal/tui/flamegraph"
+ "ior/internal/tui/messages"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// tabRenderFn is a function that renders a tab's content area given the
+// current model state and viewport dimensions. It returns the rendered string.
+type tabRenderFn func(m *Model, snap *statsengine.Snapshot, stream *eventstream.Model, flame *flamegraphtui.Model, width, height int) string
+
+// tabScrollFn handles scroll key presses for a specific tab. Returns whether
+// the key was handled and an optional tea.Cmd. It is called only when bubbles
+// are not active (bubble scroll is handled before tab dispatch).
+type tabScrollFn func(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd)
+
+// tabDescriptor captures all per-tab metadata and behaviour so that adding a
+// new tab only requires registering a new entry — no switch statements need to
+// be modified.
+type tabDescriptor struct {
+ // Name is the full display label shown in the tab bar (e.g. "Overview").
+ Name string
+ // ShortName is the abbreviated label used when the tab bar is narrow.
+ ShortName string
+ // Position controls the left-to-right order of tabs in the tab bar.
+ // Lower values appear first. The existing tabs use positions 10–70 in
+ // steps of 10 so new tabs can be inserted without renumbering.
+ Position int
+ // AllowedVizModes lists the visualization modes available for this tab.
+ // Tabs that only support the plain table view contain a single entry.
+ AllowedVizModes []tabVizMode
+ // InitCmd is an optional extra Bubble Tea command to start alongside the
+ // global refresh tick when this tab is the active tab on Init. Tabs that
+ // need their own high-frequency tick (stream, flame) set this; others leave
+ // it nil.
+ InitCmd func() tea.Cmd
+ // Render draws the tab body. Nil means the tab has no registered renderer
+ // (used for tabs that handle rendering via other paths).
+ Render tabRenderFn
+ // HandleScroll handles direction keys for this tab when bubbles are off.
+ // Nil means the tab does not process scroll/navigation keys.
+ HandleScroll tabScrollFn
+}
+
+// tabDescriptors is the central registry mapping every known Tab to its
+// descriptor. Registering a new tab here is all that is required to make it
+// participate in the tab bar, keyboard navigation, and rendering dispatch.
+var tabDescriptors = map[Tab]tabDescriptor{
+ TabFlame: {
+ Name: "Flame",
+ ShortName: "Flm",
+ Position: 10,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ InitCmd: flameTickCmd,
+ Render: tabRenderFlame,
+ HandleScroll: nil,
+ },
+ TabOverview: {
+ Name: "Overview",
+ ShortName: "Ovr",
+ Position: 20,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ Render: tabRenderOverview,
+ HandleScroll: nil,
+ },
+ TabSyscalls: {
+ Name: "Syscalls",
+ ShortName: "Sys",
+ Position: 30,
+ AllowedVizModes: []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap},
+ Render: tabRenderSyscalls,
+ HandleScroll: tabScrollSyscalls,
+ },
+ TabFiles: {
+ Name: "Files",
+ ShortName: "Fil",
+ Position: 40,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ Render: tabRenderFiles,
+ HandleScroll: tabScrollFiles,
+ },
+ TabProcesses: {
+ Name: "Processes",
+ ShortName: "Pro",
+ Position: 50,
+ AllowedVizModes: []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap},
+ Render: tabRenderProcesses,
+ HandleScroll: tabScrollProcesses,
+ },
+ TabLatency: {
+ Name: "Latency+Gaps",
+ ShortName: "Lat",
+ Position: 60,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ Render: tabRenderLatency,
+ HandleScroll: nil,
+ },
+ TabStream: {
+ Name: "Stream",
+ ShortName: "Str",
+ Position: 70,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ InitCmd: streamTickCmd,
+ Render: tabRenderStream,
+ HandleScroll: tabScrollStream,
+ },
+}
+
+// orderedTabs returns all registered tabs sorted by their Position field.
+// This is the canonical tab order used for tab bar rendering and navigation.
+// It replaces the hardcoded allTabs slice so new tabs registered in
+// tabDescriptors automatically appear in the correct position.
+func orderedTabs() []Tab {
+ tabs := make([]Tab, 0, len(tabDescriptors))
+ for tab := range tabDescriptors {
+ tabs = append(tabs, tab)
+ }
+ sort.Slice(tabs, func(i, j int) bool {
+ return tabDescriptors[tabs[i]].Position < tabDescriptors[tabs[j]].Position
+ })
+ return tabs
+}
+
+// lookupTab returns the descriptor for the given tab, falling back to a
+// sensible default when the tab is not in the registry.
+func lookupTab(tab Tab) tabDescriptor {
+ if d, ok := tabDescriptors[tab]; ok {
+ return d
+ }
+ return tabDescriptor{Name: "Unknown", ShortName: "Unk", AllowedVizModes: []tabVizMode{tabVizModeTable}}
+}
+
+// tabAllowedVizModes returns the visualisation modes allowed for tab,
+// respecting any runtime conditions (e.g. Files requires dir-grouped mode for
+// non-table views). This replaces the former allowedVizModes switch statement.
+func tabAllowedVizModes(tab Tab, filesDirGrouped bool) []tabVizMode {
+ if tab == TabFiles && filesDirGrouped {
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap, tabVizModeIcicle}
+ }
+ return lookupTab(tab).AllowedVizModes
+}
+
+// tabRenderFlame adapts the flame model's View to the tabRenderFn signature.
+func tabRenderFlame(_ *Model, _ *statsengine.Snapshot, _ *eventstream.Model, flame *flamegraphtui.Model, _, _ int) string {
+ if flame == nil {
+ return common.PanelStyle.Render("Flame: waiting for model...")
+ }
+ return flame.View().Content
+}
+
+// tabRenderOverview adapts renderOverview to the tabRenderFn signature.
+func tabRenderOverview(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ return renderOverview(snap, width, height)
+}
+
+// tabRenderSyscalls adapts renderSyscalls to the tabRenderFn signature.
+// Sort-state rendering is handled by renderActiveContentTable before this path.
+func tabRenderSyscalls(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ return renderSyscalls(snap, width, height)
+}
+
+// tabRenderFiles adapts renderFiles to the tabRenderFn signature, choosing
+// between the dir-grouped and plain view based on model state.
+// Sort-state rendering is handled by renderActiveContentTable before this path.
+func tabRenderFiles(m *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ if m.filesDirGrouped {
+ return renderFilesDirGrouped(snap, width, height, m.filesDirOffset, m.filesDirCol)
+ }
+ return renderFilesWithOffset(snap, width, height, m.filesOffset, m.filesCol)
+}
+
+// tabRenderProcesses adapts renderProcessesWithOffset to the tabRenderFn signature.
+func tabRenderProcesses(m *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ return renderProcessesWithOffset(snap, width, height, m.processesOffset, m.processesCol, m.pidFilter)
+}
+
+// tabRenderLatency adapts renderLatencyGapsTab to the tabRenderFn signature.
+func tabRenderLatency(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ return renderLatencyGapsTab(snap, width, height)
+}
+
+// tabRenderStream adapts the stream model's View to the tabRenderFn signature.
+func tabRenderStream(_ *Model, _ *statsengine.Snapshot, stream *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ if stream == nil {
+ return common.PanelStyle.Render("Stream: waiting for source...")
+ }
+ return stream.View(width, height)
+}
+
+// tabScrollSyscalls handles navigation keys for the syscalls tab. When the
+// treemap viz is active it uses offset-based navigation; otherwise table nav.
+func tabScrollSyscalls(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ keyStr := msg.String()
+ if m.syscallsVizMode == tabVizModeTreemap {
+ return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil
+ }
+ return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol,
+ m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
+}
+
+// tabScrollFiles handles navigation keys for the files tab, selecting between
+// the dir-grouped and plain navigation paths based on model state.
+func tabScrollFiles(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ keyStr := msg.String()
+ if m.filesDirGrouped {
+ return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol,
+ m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
+ }
+ return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol,
+ m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
+}
+
+// tabScrollProcesses handles navigation keys for the processes tab.
+func tabScrollProcesses(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ keyStr := msg.String()
+ return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol,
+ m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil
+}
+
+// tabScrollStream handles navigation, filter, and editor-open keys for the
+// stream tab. It delegates to streamModel and then emits the appropriate
+// Bubble Tea messages for any filter or editor requests.
+func tabScrollStream(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ streamWidth, streamHeight := streamViewport(m.width, m.height)
+ m.streamModel.SetViewport(streamWidth, streamHeight)
+ handled := m.streamModel.HandleTeaKey(msg)
+ if m.streamModel.ConsumeGlobalFilterUndoRequest() {
+ return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} }
+ }
+ if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok {
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
+ }
+ if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok {
+ return openStreamEditor(m, path)
+ }
+ return handled, nil
+}
+
+// openStreamEditor opens an external editor for the given path, recording any
+// open error into the stream model's status message so the user sees feedback.
+func openStreamEditor(m *Model, path string) (bool, tea.Cmd) {
+ editorCmd, err := eventstream.EditorCommandForPath(path)
+ if err != nil {
+ m.streamModel.SetStatusMessage("Open failed: " + err.Error())
+ return true, nil
+ }
+ return true, tea.ExecProcess(editorCmd, func(err error) tea.Msg {
+ return streamEditorDoneMsg{err: err}
+ })
+}
diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go
index ab9365f..0e9d924 100644
--- a/internal/tui/dashboard/tabs.go
+++ b/internal/tui/dashboard/tabs.go
@@ -30,52 +30,29 @@ const (
TabFlame
)
-var allTabs = []Tab{
- TabFlame,
- TabOverview,
- TabSyscalls,
- TabFiles,
- TabProcesses,
- TabLatency,
- TabStream,
-}
-
+// String returns the full display name of the tab, looked up from the
+// central tabDescriptors registry so new tabs need no switch edits here.
func (t Tab) String() string {
- switch t {
- case TabOverview:
- return "Overview"
- case TabSyscalls:
- return "Syscalls"
- case TabFiles:
- return "Files"
- case TabProcesses:
- return "Processes"
- case TabLatency:
- return "Latency+Gaps"
- case TabStream:
- return "Stream"
- case TabFlame:
- return "Flame"
- default:
- return "Unknown"
- }
+ return lookupTab(t).Name
}
func nextTab(tab Tab) Tab {
- idx := tabIndex(tab)
- return allTabs[(idx+1)%len(allTabs)]
+ tabs := orderedTabs()
+ idx := tabIndex(tab, tabs)
+ return tabs[(idx+1)%len(tabs)]
}
func prevTab(tab Tab) Tab {
- idx := tabIndex(tab)
+ tabs := orderedTabs()
+ idx := tabIndex(tab, tabs)
if idx == 0 {
- return allTabs[len(allTabs)-1]
+ return tabs[len(tabs)-1]
}
- return allTabs[idx-1]
+ return tabs[idx-1]
}
-func tabIndex(tab Tab) int {
- for i, candidate := range allTabs {
+func tabIndex(tab Tab, tabs []Tab) int {
+ for i, candidate := range tabs {
if candidate == tab {
return i
}
@@ -83,13 +60,17 @@ func tabIndex(tab Tab) int {
return 0
}
+// renderTabBar renders the full-width styled tab bar. It falls back to the
+// plain renderer when the terminal is narrow, and further degrades to showing
+// only the active tab label when even the abbreviated labels do not fit.
func renderTabBar(active Tab, width int) string {
if width > 0 && width < 90 {
return renderTabBarPlain(active, width)
}
+ tabs := orderedTabs()
build := func(short bool) string {
- parts := make([]string, 0, len(allTabs))
- for i, tab := range allTabs {
+ parts := make([]string, 0, len(tabs))
+ for i, tab := range tabs {
label := fmt.Sprintf("%d:%s", i+1, tabLabel(tab, short))
if tab == active {
parts = append(parts, common.TabActiveStyle.Render(label))
@@ -105,7 +86,7 @@ func renderTabBar(active Tab, width int) string {
bar = build(true)
}
if width > 0 && lipgloss.Width(bar) > width {
- label := fmt.Sprintf("%d:%s", tabIndex(active)+1, tabLabel(active, false))
+ label := fmt.Sprintf("%d:%s", tabIndex(active, tabs)+1, tabLabel(active, false))
bar = common.TabActiveStyle.Render(label)
}
if width <= 0 {
@@ -206,28 +187,13 @@ func wrapHelpLines(parts []string, width int) (string, string) {
return lines[0], lines[1]
}
+// tabLabel returns the display label for tab. When short is true the
+// abbreviated name from the registry is used; otherwise the full name.
func tabLabel(tab Tab, short bool) string {
if !short {
return tab.String()
}
- switch tab {
- case TabOverview:
- return "Ovr"
- case TabSyscalls:
- return "Sys"
- case TabFiles:
- return "Fil"
- case TabProcesses:
- return "Pro"
- case TabLatency:
- return "Lat"
- case TabStream:
- return "Str"
- case TabFlame:
- return "Flm"
- default:
- return "Unk"
- }
+ return lookupTab(tab).ShortName
}
func truncatePlain(s string, width int) string {
@@ -244,9 +210,13 @@ func truncatePlain(s string, width int) string {
return string(r[:width-1]) + "…"
}
+// renderTabBarPlain renders a plain-text tab bar suitable for narrow terminals.
+// Tab order and labels are derived from the registry so no edits are needed
+// when new tabs are registered.
func renderTabBarPlain(active Tab, width int) string {
- parts := make([]string, 0, len(allTabs))
- for i, tab := range allTabs {
+ tabs := orderedTabs()
+ parts := make([]string, 0, len(tabs))
+ for i, tab := range tabs {
label := fmt.Sprintf("%d:%s", i+1, tabLabel(tab, true))
if tab == active {
label = "[" + label + "]"