diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:44:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:44:34 +0200 |
| commit | 1943f4395d477c9a0f9dad4ce78339b7f1163862 (patch) | |
| tree | c07545eb40cdcc15868c6ec994933623f531d170 /internal | |
| parent | 5d6b2cff5fa13700fdfcc30d7e30f5cece2e6d38 (diff) | |
task 361: add flamegraph control actions and toolbar
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/dashboard/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 97 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 37 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 61 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 9 |
5 files changed, 198 insertions, 8 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index e8c1cb2..a1aaf89 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -116,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activeTab != TabFlame { return m, nil } - if m.liveTrie != nil && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { + if m.liveTrie != nil && !m.flamegraphModel.Paused() && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { m.flamegraphModel.RefreshFromLiveTrie() } return m, flameTickCmd() diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go new file mode 100644 index 0000000..240ba90 --- /dev/null +++ b/internal/tui/flamegraph/controls.go @@ -0,0 +1,97 @@ +package flamegraph + +import ( + "fmt" + common "ior/internal/tui/common" + "strings" + + "charm.land/lipgloss/v2" +) + +func (m *Model) togglePause() { + m.paused = !m.paused +} + +func (m *Model) resetBaseline() { + if m.liveTrie != nil { + m.liveTrie.Reset() + } + m.zoomRoot = nil + m.zoomPath = "" + m.zoomStack = nil + m.selectedIdx = 0 + m.snapshot = nil + m.frames = nil + m.targetFrames = nil + m.searchQuery = "" + m.matchIndices = make(map[int]bool) + m.subtreeSet = make(map[int]bool) + m.statusMessage = "Baseline reset" +} + +func (m *Model) cycleFieldOrder() { + if len(m.fieldPresets) == 0 { + return + } + m.fieldIndex = (m.fieldIndex + 1) % len(m.fieldPresets) + nextPreset := m.fieldPresets[m.fieldIndex] + if m.liveTrie != nil { + if err := m.liveTrie.Reconfigure(nextPreset); err != nil { + m.statusMessage = "Field order error: " + err.Error() + return + } + } + m.zoomRoot = nil + m.zoomPath = "" + m.zoomStack = nil + m.selectedIdx = 0 + m.snapshot = nil + m.frames = nil + m.targetFrames = nil + m.subtreeSet = make(map[int]bool) + m.statusMessage = "Order: " + strings.Join(nextPreset, "/") +} + +func (m *Model) toggleHelp() { + m.showHelp = !m.showHelp +} + +func (m Model) toolbarLine() string { + state := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("[LIVE]") + if m.paused { + state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") + } + order := m.currentFieldPresetLabel() + line := fmt.Sprintf("%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, order) + if m.statusMessage != "" { + line += " | " + m.statusMessage + } + width := m.width + if width <= 0 { + width = 80 + } + return padOrTrim(line, width) +} + +func (m Model) helpOverlay() string { + width := m.width + if width <= 0 { + width = 80 + } + help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches p pause r reset baseline o order ? help" + return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width)) +} + +func (m Model) currentFieldPresetLabel() string { + if len(m.fieldPresets) == 0 { + return "n/a" + } + idx := m.fieldIndex + if idx < 0 { + idx = 0 + } + if idx >= len(m.fieldPresets) { + idx = len(m.fieldPresets) - 1 + } + return strings.Join(m.fieldPresets[idx], "/") +} diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 0363b58..ca3bab2 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -65,11 +65,13 @@ type Model struct { zoomRoot *snapshotNode zoomPath string - searchActive bool - searchInput textinput.Model - searchQuery string - matchIndices map[int]bool - subtreeSet map[int]bool + searchActive bool + searchInput textinput.Model + searchQuery string + matchIndices map[int]bool + subtreeSet map[int]bool + showHelp bool + statusMessage string fieldPresets [][]string fieldIndex int @@ -107,9 +109,10 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { subtreeSet: make(map[int]bool), searchInput: searchInput, fieldPresets: [][]string{ - {"comm", "path"}, + {"comm", "path", "tracepoint"}, + {"path", "tracepoint", "comm"}, {"tracepoint", "comm", "path"}, - {"pid", "tid", "comm", "path"}, + {"pid", "path", "tracepoint"}, }, isDark: true, keys: defaultFlameKeyMap(), @@ -150,6 +153,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.jumpMatch(1) case msg.String() == "N": m.jumpMatch(-1) + case msg.String() == "p": + m.togglePause() + case msg.String() == "r": + m.resetBaseline() + case msg.String() == "o": + m.cycleFieldOrder() + case msg.String() == "?": + m.toggleHelp() case key.Matches(msg, m.keys.ZoomIn): m.zoomIn() case key.Matches(msg, m.keys.ZoomUndo): @@ -175,12 +186,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the flamegraph viewport. func (m Model) View() tea.View { content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark, m.searchActive, m.searchQuery) + content = replaceHeaderLine(content, m.toolbarLine()) if m.searchActive { content = replaceFooterLine(content, m.searchFooter()) } if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } + if m.showHelp { + content += "\n" + m.helpOverlay() + } return tea.NewView(content) } @@ -203,6 +218,9 @@ func (m *Model) RefreshFromLiveTrie() bool { if m.liveTrie == nil { return false } + if m.paused { + return false + } version := m.liveTrie.Version() if version == m.lastVersion && m.snapshot != nil { return false @@ -229,6 +247,11 @@ func (m Model) LastVersion() uint64 { return m.lastVersion } +// Paused reports whether live refresh is paused. +func (m Model) Paused() bool { + return m.paused +} + // SetViewport updates model render dimensions. func (m *Model) SetViewport(width, height int) { m.width = width diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 413b571..5271dee 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -2,6 +2,7 @@ package flamegraph import ( coreflamegraph "ior/internal/flamegraph" + "reflect" "testing" tea "charm.land/bubbletea/v2" @@ -214,6 +215,66 @@ func TestSearchEscapeClearsState(t *testing.T) { } } +func TestControlPauseToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if !m.paused { + t.Fatalf("expected pause to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if m.paused { + t.Fatalf("expected pause to toggle off") + } +} + +func TestControlResetBaseline(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + m.snapshot = &snapshotNode{Name: "root", Total: 10} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.zoomPath = "root" + m.zoomStack = []zoomState{{path: "", previousSelectedIdx: 0}} + m.selectedIdx = 3 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'r'}[0], Text: "r"}) + if m.snapshot != nil || len(m.frames) != 0 || len(m.zoomStack) != 0 || m.zoomPath != "" { + t.Fatalf("expected baseline reset to clear snapshot/layout/zoom state") + } + if m.statusMessage != "Baseline reset" { + t.Fatalf("expected reset status message, got %q", m.statusMessage) + } +} + +func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + initial := append([]string(nil), m.fieldPresets[m.fieldIndex]...) + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'o'}[0], Text: "o"}) + if m.fieldIndex != 1 { + t.Fatalf("expected field index to advance to 1, got %d", m.fieldIndex) + } + next := m.fieldPresets[m.fieldIndex] + if reflect.DeepEqual(initial, next) { + t.Fatalf("expected next field preset to differ from initial") + } + if got := liveTrie.Fields(); !reflect.DeepEqual(got, next) { + t.Fatalf("expected live trie fields %v, got %v", next, got) + } +} + +func TestControlHelpToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if !m.showHelp { + t.Fatalf("expected help overlay to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if m.showHelp { + t.Fatalf("expected help overlay to toggle off") + } +} + func newZoomModel() Model { m := NewModel(nil) m.width = 120 diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go index c1d4294..f2d6dc0 100644 --- a/internal/tui/flamegraph/search.go +++ b/internal/tui/flamegraph/search.go @@ -95,3 +95,12 @@ func replaceFooterLine(content, footer string) string { lines[len(lines)-1] = footer return strings.Join(lines, "\n") } + +func replaceHeaderLine(content, header string) string { + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return header + } + lines[0] = header + return strings.Join(lines, "\n") +} |
