summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:44:34 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:44:34 +0200
commit1943f4395d477c9a0f9dad4ce78339b7f1163862 (patch)
treec07545eb40cdcc15868c6ec994933623f531d170 /internal
parent5d6b2cff5fa13700fdfcc30d7e30f5cece2e6d38 (diff)
task 361: add flamegraph control actions and toolbar
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/dashboard/model.go2
-rw-r--r--internal/tui/flamegraph/controls.go97
-rw-r--r--internal/tui/flamegraph/model.go37
-rw-r--r--internal/tui/flamegraph/model_test.go61
-rw-r--r--internal/tui/flamegraph/search.go9
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")
+}