diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/dashboard/model.go | 350 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 75 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabregistry.go | 257 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 86 |
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 + "]" |
