diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
| commit | 1561987330cb898f5ff64383a9c78e7e6559f118 (patch) | |
| tree | 69a823e8f98dce572566c97e6879c11c9d591bda /internal/tui/dashboard | |
| parent | 96225fb6159212a8851043a08d781aba721b4e78 (diff) | |
| parent | 110a193e04b81abb8d8e159abd73f9f6ed1acd7e (diff) | |
Merge branch 'feat/bubbletea-v2-migration'
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/doc.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 14 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/layout.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 218 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 234 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 45 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 33 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 85 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 82 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 12 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 15 |
15 files changed, 543 insertions, 218 deletions
diff --git a/internal/tui/dashboard/doc.go b/internal/tui/dashboard/doc.go new file mode 100644 index 0000000..b9bc30e --- /dev/null +++ b/internal/tui/dashboard/doc.go @@ -0,0 +1,2 @@ +// Package dashboard implements the multi-tab runtime dashboard used in TUI mode. +package dashboard diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index 80e3037..d43e215 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -2,12 +2,13 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" "path/filepath" "sort" "strconv" - "github.com/charmbracelet/bubbles/table" + "ior/internal/statsengine" + + "charm.land/bubbles/v2/table" ) type DirSnapshot struct { diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 7613230..28f5b2b 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -2,11 +2,12 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" - common "ior/internal/tui/common" "math" "strconv" "strings" + + "ior/internal/statsengine" + common "ior/internal/tui/common" ) func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { @@ -14,9 +15,10 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Latency: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -27,9 +29,10 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Gaps: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -53,6 +56,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he if width <= 0 { width = 80 } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) if height > 0 { @@ -93,7 +97,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he } lines = append(lines, "Scale: █▓▒░") - return common.PanelStyle.Width(panelInner).Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n")) } func renderHistogramBar(count, maxCount uint64, width int) string { diff --git a/internal/tui/dashboard/histogram_test.go b/internal/tui/dashboard/histogram_test.go index 7790394..48297a2 100644 --- a/internal/tui/dashboard/histogram_test.go +++ b/internal/tui/dashboard/histogram_test.go @@ -6,7 +6,7 @@ import ( "ior/internal/statsengine" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestRenderHistogramNoBuckets(t *testing.T) { diff --git a/internal/tui/dashboard/layout.go b/internal/tui/dashboard/layout.go index 0035a9d..75cbafb 100644 --- a/internal/tui/dashboard/layout.go +++ b/internal/tui/dashboard/layout.go @@ -4,7 +4,3 @@ const panelHorizontalChrome = 4 // Keep a small guard so sparkline rows never soft-wrap in panel cells. const sparklineSafetyMargin = 3 - -// Stats engine currently provides 120 time-series slots; cap rendering width -// so wide terminals don't introduce wrap/placement artifacts. -const sparklineMaxWidth = 120 diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index fc9caf6..d10a91a 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,20 +1,26 @@ package dashboard import ( + "strings" + "time" + "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" + flamegraphtui "ior/internal/tui/flamegraph" "ior/internal/tui/messages" - "strings" - "time" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" ) const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const flameRefreshMs = 200 const streamChromeRows = 4 +const dashboardHelpHintRows = 1 +const dashboardExpandedHelpRows = 2 +const dashboardTabBarRows = 1 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -23,6 +29,7 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type flameTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -31,8 +38,9 @@ type streamEditorDoneMsg struct { type Model struct { activeTab Tab - engine SnapshotSource - latest *statsengine.Snapshot + engine SnapshotSource + latest *statsengine.Snapshot + liveTrie flamegraphtui.LiveTrieSource width int height int @@ -46,32 +54,50 @@ type Model struct { filesDirOffset int processesOffset int streamModel eventstream.Model + flamegraphModel flamegraphtui.Model showHelp bool + isDark bool + focused bool } // NewModel creates a dashboard model with default refresh cadence. -func NewModel(engine SnapshotSource, streamSource *eventstream.RingBuffer) Model { +func NewModel(engine SnapshotSource, streamSource eventstream.Source) Model { return NewModelWithConfig(engine, streamSource, defaultRefreshMs, common.Keys) } // NewModelWithConfig creates a dashboard model with explicit refresh and keys. -func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuffer, refreshMs int, keys common.KeyMap) Model { +func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source, refreshMs int, keys common.KeyMap) Model { if refreshMs <= 0 { refreshMs = defaultRefreshMs } - return Model{ - activeTab: TabOverview, - engine: engine, - refreshEvery: time.Duration(refreshMs) * time.Millisecond, - keys: keys, - pidFilter: -1, - streamModel: eventstream.NewModel(streamSource), + m := Model{ + activeTab: TabFlame, + engine: engine, + refreshEvery: time.Duration(refreshMs) * time.Millisecond, + keys: keys, + pidFilter: -1, + streamModel: eventstream.NewModel(streamSource), + flamegraphModel: flamegraphtui.NewModel(nil), + isDark: true, + focused: true, } + m.SetDarkMode(true) + return m } // Init starts periodic refresh ticks. func (m Model) Init() tea.Cmd { - return tickCmd(m.refreshEvery) + cmds := []tea.Cmd{tickCmd(m.refreshEvery)} + switch m.activeTab { + case TabStream: + cmds = append(cmds, streamTickCmd()) + case TabFlame: + cmds = append(cmds, flameTickCmd()) + } + if len(cmds) == 1 { + return cmds[0] + } + return tea.Batch(cmds...) } // Update handles ticks, snapshots, tab changes, and resize events. @@ -82,19 +108,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) m.streamModel.SetViewport(streamWidth, streamHeight) + flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil case refreshTickMsg: + if !m.focused { + return m, nil + } snap := m.snapshot() return m, tea.Batch( tickCmd(m.refreshEvery), func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }, ) case streamTickMsg: + if !m.focused { + return m, nil + } if m.activeTab != TabStream { return m, nil } m.streamModel.Refresh() return m, streamTickCmd() + case flameTickMsg: + if !m.focused { + return m, nil + } + if m.activeTab != TabFlame { + return m, nil + } + var animCmd tea.Cmd + if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() { + animCmd = m.flamegraphModel.AnimationCmd() + } + if animCmd != nil { + return m, tea.Batch(flameTickCmd(), animCmd) + } + return m, flameTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -103,7 +152,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) m.streamModel.Refresh() return m, nil - case tea.KeyMsg: + case tea.KeyPressMsg: return m.handleKey(msg) case streamEditorDoneMsg: if msg.err != nil { @@ -111,17 +160,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.activeTab == TabFlame { + next, cmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, cmd + } return m, nil } -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() if keyStr == "H" { m.showHelp = !m.showHelp + flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil } + if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } handled, scrollCmd := m.handleScrollKey(msg) if scrollCmd != nil { cmd = scrollCmd @@ -132,29 +193,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !handled { switch { + case key.Matches(msg, m.keys.One): + m.activeTab = TabFlame + handled = true case key.Matches(msg, m.keys.Tab): m.activeTab = nextTab(m.activeTab) handled = true case key.Matches(msg, m.keys.ShiftTab): m.activeTab = prevTab(m.activeTab) handled = true - case key.Matches(msg, m.keys.One): - m.activeTab = TabOverview - handled = true case key.Matches(msg, m.keys.Two): - m.activeTab = TabSyscalls + m.activeTab = TabOverview handled = true case key.Matches(msg, m.keys.Three): - m.activeTab = TabFiles + m.activeTab = TabSyscalls handled = true case key.Matches(msg, m.keys.Four): - m.activeTab = TabProcesses + m.activeTab = TabFiles handled = true case key.Matches(msg, m.keys.Five): - m.activeTab = TabLatency + m.activeTab = TabProcesses handled = true case key.Matches(msg, m.keys.Six): - m.activeTab = TabStream + m.activeTab = TabLatency handled = true case key.Matches(msg, m.keys.Seven): m.activeTab = TabStream @@ -171,18 +232,34 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } if !handled { + if m.activeTab == TabFlame { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } return m, nil } + batch := make([]tea.Cmd, 0, 3) + if cmd != nil { + batch = append(batch, cmd) + } if prevActiveTab != TabStream && m.activeTab == TabStream { - if cmd == nil { - return m, streamTickCmd() - } - return m, tea.Batch(cmd, streamTickCmd()) + batch = append(batch, streamTickCmd()) + } + if prevActiveTab != TabFlame && m.activeTab == TabFlame { + batch = append(batch, flameTickCmd()) + } + switch len(batch) { + case 0: + return m, nil + case 1: + return m, batch[0] + default: + return m, tea.Batch(batch...) } - return m, cmd } -func (m *Model) handleScrollKey(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { keyStr := msg.String() switch m.activeTab { case TabSyscalls: @@ -271,26 +348,60 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } -// BlocksGlobalShortcuts reports whether modal UI in the active tab should -// suppress top-level shortcuts (for example global export key handling). -func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()) +// BlocksGlobalShortcuts reports whether the active tab should suppress a +// top-level shortcut for the given key press. +func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { + if m.activeTab == TabStream { + return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() + } + if m.activeTab == TabFlame { + return m.flamegraphModel.ConsumesKey(msg) + } + return false } // SetStreamSource updates the live stream source used by the stream tab. -func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { +func (m *Model) SetStreamSource(source eventstream.Source) { m.streamModel.SetSource(source) } +// SetLiveTrie updates the live trie source used by the flamegraph tab. +func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { + m.liveTrie = liveTrie + m.flamegraphModel.SetLiveTrie(liveTrie) + if m.width > 0 && m.height > 0 { + m.flamegraphModel.SetViewport(m.width, m.height) + } + m.flamegraphModel.RefreshFromLiveTrie() +} + +// SetDarkMode updates dashboard child models for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.streamModel.SetDarkMode(isDark) + m.flamegraphModel.SetDarkMode(isDark) +} + +// SetFocused controls whether periodic refresh ticks are processed. +func (m *Model) SetFocused(focused bool) { + m.focused = focused +} + +// SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot. +func (m Model) SnapshotCmd() tea.Cmd { + snap := m.snapshot() + return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } +} + // SetPidFilter updates the active PID filter used by tab render hints. func (m *Model) SetPidFilter(pid int) { m.pidFilter = pid } // View renders the tab bar, active tab scaffold, and help bar. -func (m Model) View() string { +func (m Model) View() tea.View { width, height := common.EffectiveViewport(m.width, m.height) - activeHeight := height + _, activeHeight := flameViewport(width, height, m.showHelp) streamModel := m.streamModel streamModel.SetFooterVisible(m.showHelp) if m.activeTab == TabStream { @@ -304,6 +415,7 @@ func (m Model) View() string { m.activeTab, m.latest, &streamModel, + &m.flamegraphModel, width, activeHeight, m.pidFilter, @@ -319,20 +431,27 @@ func (m Model) View() string { } else { b.WriteString(renderHelpHint(width)) } - return common.ScreenStyle.Render(b.String()) + return tea.NewView(common.ScreenStyle.Render(b.String())) } 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, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset 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...") + } + flameModel.SetViewport(width, height) + return flameModel.View().Content + } if snap == nil { return common.PanelStyle.Render(tab.String() + ": waiting for stats...") @@ -361,6 +480,10 @@ func streamTickCmd() tea.Cmd { return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} }) } +func flameTickCmd() tea.Cmd { + return tea.Tick(flameRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return flameTickMsg{} }) +} + func streamViewport(width, height int) (int, int) { width, height = common.EffectiveViewport(width, height) height -= streamChromeRows @@ -369,3 +492,16 @@ func streamViewport(width, height int) (int, int) { } return width, height } + +func flameViewport(width, height int, showHelp bool) (int, int) { + width, height = common.EffectiveViewport(width, height) + chromeRows := dashboardTabBarRows + dashboardHelpHintRows + if showHelp { + chromeRows = dashboardTabBarRows + dashboardExpandedHelpRows + } + height -= chromeRows + if height < 1 { + height = 1 + } + return width, height +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 87b60e3..d5b78e0 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -7,12 +7,13 @@ import ( "strings" "testing" + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" "ior/internal/tui/messages" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) type fakeSnapshotSource struct { @@ -28,59 +29,60 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { func TestKeySwitchingChangesActiveTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) model := next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected syscalls tab, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected overview tab on key 2, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) model = next.(Model) - if model.activeTab != TabFiles { - t.Fatalf("expected next tab to be files, got %v", model.activeTab) + if model.activeTab != TabSyscalls { + t.Fatalf("expected next tab to be syscalls, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) model = next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected previous tab to be overview, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) model = next.(Model) if model.activeTab != TabStream { t.Fatalf("expected stream tab on key 7, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'6'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'1'}[0], Text: string([]rune{'1'})}) model = next.(Model) - if model.activeTab != TabStream { - t.Fatalf("expected stream tab on key 6, got %v", model.activeTab) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab on key 1, got %v", model.activeTab) } } func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabOverview - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) model := next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected right arrow not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'l'}[0], Text: string([]rune{'l'})}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected l not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected left arrow not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'h'}[0], Text: string([]rune{'h'})}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected h not to change tabs, got %v", model.activeTab) @@ -93,13 +95,13 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}, {Name: "write", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.syscallsOffset != 1 { t.Fatalf("expected offset 1 after j, got %d", model.syscallsOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.syscallsOffset != 0 { t.Fatalf("expected offset 0 after k, got %d", model.syscallsOffset) @@ -112,13 +114,13 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1}, {PID: 2}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.processesOffset != 1 { t.Fatalf("expected processes offset 1 after j, got %d", model.processesOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.processesOffset != 0 { t.Fatalf("expected processes offset 0 after k, got %d", model.processesOffset) @@ -131,13 +133,13 @@ func TestFilesTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}, {Path: "/b"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.filesOffset != 1 { t.Fatalf("expected files offset 1 after j, got %d", model.filesOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.filesOffset != 0 { t.Fatalf("expected files offset 0 after k, got %d", model.filesOffset) @@ -155,7 +157,7 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) { }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.filesDirOffset != 1 { t.Fatalf("expected grouped dir offset 1 after j, got %d", model.filesDirOffset) @@ -171,13 +173,73 @@ func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { m.activeTab = TabStream m.streamModel.HandleKey("space") // pause - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) _ = next if cmd == nil { t.Fatalf("expected stream tick command when unpausing stream") } } +func TestFlameTickRefreshesFlamegraphModel(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + liveTrie.Reset() + + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, cmd := m.Update(flameTickMsg{}) + model := next.(Model) + if cmd == nil { + t.Fatalf("expected flame tick to schedule next tick command") + } + if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want { + t.Fatalf("expected flame model version %d, got %d", want, got) + } +} + +func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + if !m.flamegraphModel.HasSnapshot() { + t.Fatalf("expected SetLiveTrie to preload a baseline snapshot") + } + + next, _ := m.Update(flameTickMsg{}) + model := next.(Model) + if !model.flamegraphModel.HasSnapshot() { + t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged") + } +} + +func TestFlameTickPausedFreezesAfterInitialSnapshot(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + model := next.(Model) + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + initialVersion := model.flamegraphModel.LastVersion() + + liveTrie.Reset() + if liveTrie.Version() == initialVersion { + t.Fatalf("expected reset to advance trie version") + } + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + if got, want := model.flamegraphModel.LastVersion(), initialVersion; got != want { + t.Fatalf("expected paused flame tick to freeze version at %d, got %d", want, got) + } +} + func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { rb := eventstream.NewRingBuffer() for i := 0; i < 300; i++ { @@ -200,34 +262,34 @@ func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { m.streamModel.Refresh() _ = m.View() - next, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) // pause m = next.(Model) - before := rowFromStreamView(t, m.View()) + before := rowFromStreamView(t, m.View().Content) - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) m = next.(Model) - afterK := rowFromStreamView(t, m.View()) + afterK := rowFromStreamView(t, m.View().Content) if afterK >= before { t.Fatalf("expected k to scroll up while paused: before=%d afterK=%d", before, afterK) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = next.(Model) - afterDown := rowFromStreamView(t, m.View()) + afterDown := rowFromStreamView(t, m.View().Content) if afterDown <= afterK { t.Fatalf("expected down arrow to scroll down while paused: afterK=%d afterDown=%d", afterK, afterDown) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) m = next.(Model) - afterPgUp := rowFromStreamView(t, m.View()) + afterPgUp := rowFromStreamView(t, m.View().Content) if afterPgUp >= afterDown { t.Fatalf("expected pgup to scroll up while paused: afterDown=%d afterPgUp=%d", afterDown, afterPgUp) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) m = next.(Model) - afterPgDown := rowFromStreamView(t, m.View()) + afterPgDown := rowFromStreamView(t, m.View().Content) if afterPgDown <= afterPgUp { t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) } @@ -251,14 +313,14 @@ func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) model := next.(Model) if !model.filesDirGrouped { t.Fatalf("expected filesDirGrouped to toggle on files tab") } model.activeTab = TabOverview - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) model = next.(Model) if !model.filesDirGrouped { t.Fatalf("expected filesDirGrouped unchanged outside files tab") @@ -272,7 +334,7 @@ func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { m.latest = &snap for i := 0; i < 50; i++ { - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) m = next.(Model) } if m.syscallsOffset != 1 { @@ -284,7 +346,8 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 13} engine := &fakeSnapshotSource{snap: snap} m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m.activeTab = TabOverview + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) _ = next if cmd == nil { t.Fatalf("expected refresh command") @@ -299,6 +362,63 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { } } +func TestFlameTabReceivesSlashKey(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if cmd != nil { + t.Fatalf("did not expect global command for flame search key") + } + if !strings.Contains(model.View().Content, "0/0 matches") { + t.Fatalf("expected flame search footer after pressing /") + } +} + +func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + model := next.(Model) + if !strings.Contains(model.View().Content, "[PAUSED]") { + t.Fatalf("expected flame space key to toggle paused state") + } + + next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) + model = next.(Model) + if cmd != nil { + t.Fatalf("expected flame reset key to be handled by flame tab without global refresh command") + } + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after reset key") + } +} + +func TestFlameSearchConsumesNumericTabKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after opening search") + } + |
