summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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 + "]"