summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 09:05:51 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 09:05:51 +0200
commit10c5d48413afaef88626419d8c4bf9fbf6f1c902 (patch)
treefe6c86eaf16c16070aa8025e207e5d88bd5595c6
parent0a69582e7f8111c2a508d8f062de91a06f296974 (diff)
Fix flamegraph navigation, filtering, and system-share feedback
-rw-r--r--internal/tui/dashboard/model.go2
-rw-r--r--internal/tui/dashboard/model_test.go17
-rw-r--r--internal/tui/flamegraph/controls.go5
-rw-r--r--internal/tui/flamegraph/model.go221
-rw-r--r--internal/tui/flamegraph/model_test.go43
-rw-r--r--internal/tui/flamegraph/renderer.go204
-rw-r--r--internal/tui/flamegraph/renderer_test.go73
-rw-r--r--internal/tui/flamegraph/search.go40
8 files changed, 530 insertions, 75 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index ebd0c03..7ec1362 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -134,7 +134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
var animCmd tea.Cmd
- if m.liveTrie != nil && !m.flamegraphModel.Paused() && m.liveTrie.Version() != m.flamegraphModel.LastVersion() {
+ if m.liveTrie != nil && !m.flamegraphModel.Paused() && (!m.flamegraphModel.HasSnapshot() || m.liveTrie.Version() != m.flamegraphModel.LastVersion()) {
m.flamegraphModel.RefreshFromLiveTrie()
animCmd = m.flamegraphModel.AnimationCmd()
}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 6d35d5a..1dc1cc1 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -198,6 +198,23 @@ func TestFlameTickRefreshesFlamegraphModel(t *testing.T) {
}
}
+func TestFlameTickLoadsInitialSnapshotWithoutVersionChange(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 fresh flame model to start without snapshot")
+ }
+
+ next, _ := m.Update(flameTickMsg{})
+ model := next.(Model)
+ if !model.flamegraphModel.HasSnapshot() {
+ t.Fatalf("expected flame tick to load initial snapshot even when trie version is unchanged")
+ }
+}
+
func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
rb := eventstream.NewRingBuffer()
for i := 0; i < 300; i++ {
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 74a248d..959a5b0 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -22,10 +22,12 @@ func (m *Model) resetBaseline() {
m.zoomStack = nil
m.selectedIdx = 0
m.snapshot = nil
+ m.globalTotal = 0
m.frames = nil
m.targetFrames = nil
m.searchQuery = ""
m.matchIndices = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
m.statusMessage = "Baseline reset"
}
@@ -47,8 +49,11 @@ func (m *Model) cycleFieldOrder() {
m.zoomStack = nil
m.selectedIdx = 0
m.snapshot = nil
+ m.globalTotal = 0
m.frames = nil
m.targetFrames = nil
+ m.matchIndices = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
m.statusMessage = "Order: " + strings.Join(nextPreset, "/")
}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 27e356b..cca2fe5 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -5,6 +5,7 @@ import (
"fmt"
"image/color"
"sort"
+ "strings"
"time"
coreflamegraph "ior/internal/flamegraph"
@@ -58,6 +59,7 @@ type Model struct {
liveTrie *coreflamegraph.LiveTrie
lastVersion uint64
snapshot *snapshotNode
+ globalTotal uint64
frames []tuiFrame
targetFrames []tuiFrame
@@ -73,6 +75,7 @@ type Model struct {
searchInput textinput.Model
searchQuery string
matchIndices map[int]bool
+ filterVisible map[int]bool
subtreeSet map[int]bool
showHelp bool
statusMessage string
@@ -109,10 +112,11 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model {
searchInput.SetStyles(textinput.DefaultStyles(true))
return Model{
- liveTrie: liveTrie,
- matchIndices: make(map[int]bool),
- subtreeSet: make(map[int]bool),
- searchInput: searchInput,
+ liveTrie: liveTrie,
+ matchIndices: make(map[int]bool),
+ filterVisible: make(map[int]bool),
+ subtreeSet: make(map[int]bool),
+ searchInput: searchInput,
fieldPresets: [][]string{
{"comm", "path", "tracepoint"},
{"path", "tracepoint", "comm"},
@@ -173,33 +177,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
prev := m.selectedIdx
switch {
- case msg.String() == "/":
+ case isSearchOpenKey(msg):
m.openSearch()
- case msg.String() == "n":
+ case isNextMatchKey(msg):
m.jumpMatch(1)
- case msg.String() == "N":
+ case isPrevMatchKey(msg):
m.jumpMatch(-1)
- case msg.String() == "p":
+ case isPauseKey(msg):
m.togglePause()
- case msg.String() == "r":
+ case isResetBaselineKey(msg):
m.resetBaseline()
- case msg.String() == "o":
+ case isCycleOrderKey(msg):
m.cycleFieldOrder()
- case msg.String() == "?":
+ case isHelpToggleKey(msg):
m.toggleHelp()
- case key.Matches(msg, m.keys.ZoomIn):
+ case isZoomInKey(msg, m.keys):
m.zoomIn()
- case key.Matches(msg, m.keys.ZoomUndo):
+ case isZoomUndoKey(msg, m.keys):
m.zoomUndo()
- case key.Matches(msg, m.keys.ZoomReset):
+ case isZoomResetKey(msg, m.keys):
m.zoomReset()
- case key.Matches(msg, m.keys.MoveShallower):
+ case isMoveShallowerKey(msg, m.keys):
m.moveVerticalWithFallback(-1, 1)
- case key.Matches(msg, m.keys.MoveDeeper):
+ case isMoveDeeperKey(msg, m.keys):
m.moveVerticalWithFallback(1, -1)
- case key.Matches(msg, m.keys.PrevSibling):
+ case isPrevSiblingKey(msg, m.keys):
m.moveSibling(-1)
- case key.Matches(msg, m.keys.NextSibling):
+ case isNextSiblingKey(msg, m.keys):
m.moveSibling(1)
}
if m.selectedIdx != prev {
@@ -216,21 +220,21 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
return true
}
switch {
- case msg.String() == "/",
- msg.String() == "n",
- msg.String() == "N",
- msg.String() == "p",
- msg.String() == "r",
- msg.String() == "o",
- msg.String() == "?":
+ case isSearchOpenKey(msg),
+ isNextMatchKey(msg),
+ isPrevMatchKey(msg),
+ isPauseKey(msg),
+ isResetBaselineKey(msg),
+ isCycleOrderKey(msg),
+ isHelpToggleKey(msg):
return true
- case key.Matches(msg, m.keys.ZoomIn),
- key.Matches(msg, m.keys.ZoomUndo),
- key.Matches(msg, m.keys.ZoomReset),
- key.Matches(msg, m.keys.MoveShallower),
- key.Matches(msg, m.keys.MoveDeeper),
- key.Matches(msg, m.keys.PrevSibling),
- key.Matches(msg, m.keys.NextSibling):
+ case isZoomInKey(msg, m.keys),
+ isZoomUndoKey(msg, m.keys),
+ isZoomResetKey(msg, m.keys),
+ isMoveShallowerKey(msg, m.keys),
+ isMoveDeeperKey(msg, m.keys),
+ isPrevSiblingKey(msg, m.keys),
+ isNextSiblingKey(msg, m.keys):
return true
default:
return false
@@ -239,7 +243,7 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
// 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 := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.isDark, m.searchActive, m.searchQuery)
content = replaceHeaderLine(content, m.toolbarLine())
if m.searchActive {
content = replaceFooterLine(content, m.searchFooter())
@@ -258,6 +262,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
m.liveTrie = liveTrie
m.lastVersion = 0
m.snapshot = nil
+ m.globalTotal = 0
m.selectedIdx = 0
m.frames = nil
m.targetFrames = nil
@@ -265,6 +270,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
m.zoomRoot = nil
m.zoomPath = ""
m.subtreeSet = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
m.animation = NewAnimationState(30, 6.0, 1.0)
m.animating = false
}
@@ -288,6 +294,7 @@ func (m *Model) RefreshFromLiveTrie() bool {
return false
}
m.snapshot = &snapshot
+ m.globalTotal = snapshotTotal(m.snapshot)
if m.zoomPath != "" {
m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
} else {
@@ -303,6 +310,11 @@ func (m Model) LastVersion() uint64 {
return m.lastVersion
}
+// HasSnapshot reports whether the flamegraph model has loaded at least one snapshot.
+func (m Model) HasSnapshot() bool {
+ return m.snapshot != nil
+}
+
// AnimationCmd returns a frame animation tick command when animation is active.
func (m Model) AnimationCmd() tea.Cmd {
if !m.animating {
@@ -348,6 +360,8 @@ func (m *Model) rebuildFrames(animate bool) {
m.frames = append(m.frames[:0], m.targetFrames...)
}
m.clampSelection()
+ m.recomputeFilterState()
+ m.ensureSelectionNavigable()
m.ensureSelectionVisible()
m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
}
@@ -418,9 +432,10 @@ func (m *Model) moveVertical(delta int) {
return
}
m.clampSelection()
+ m.ensureSelectionNavigable()
current := m.frames[m.selectedIdx]
targetDepth := current.Depth + delta
- targets := framesAtDepth(m.frames, targetDepth)
+ targets := m.framesAtDepth(targetDepth)
if len(targets) == 0 {
return
}
@@ -449,8 +464,9 @@ func (m *Model) moveSibling(delta int) {
return
}
m.clampSelection()
+ m.ensureSelectionNavigable()
current := m.frames[m.selectedIdx]
- siblings := framesAtDepth(m.frames, current.Depth)
+ siblings := m.framesAtDepth(current.Depth)
if len(siblings) <= 1 {
return
}
@@ -469,11 +485,18 @@ func (m *Model) moveSibling(delta int) {
}
func framesAtDepth(frames []tuiFrame, depth int) []int {
+ return framesAtDepthFiltered(frames, depth, nil)
+}
+
+func framesAtDepthFiltered(frames []tuiFrame, depth int, include map[int]bool) []int {
if depth < 0 {
return nil
}
indices := make([]int, 0)
for idx, frame := range frames {
+ if include != nil && !include[idx] {
+ continue
+ }
if frame.Depth == depth {
indices = append(indices, idx)
}
@@ -527,6 +550,125 @@ func (m Model) currentRootPath() string {
return m.frames[0].Path
}
+func (m Model) filterActive() bool {
+ return strings.TrimSpace(m.searchQuery) != ""
+}
+
+func (m Model) navigableFrameSet() map[int]bool {
+ if !m.filterActive() {
+ return nil
+ }
+ return m.filterVisible
+}
+
+func (m Model) framesAtDepth(depth int) []int {
+ return framesAtDepthFiltered(m.frames, depth, m.navigableFrameSet())
+}
+
+func (m Model) frameNavigable(idx int) bool {
+ if idx < 0 || idx >= len(m.frames) {
+ return false
+ }
+ if !m.filterActive() {
+ return true
+ }
+ return m.filterVisible[idx]
+}
+
+func (m *Model) ensureSelectionNavigable() {
+ if len(m.frames) == 0 {
+ m.selectedIdx = 0
+ return
+ }
+ m.clampSelection()
+ if m.frameNavigable(m.selectedIdx) {
+ return
+ }
+
+ if len(m.matchIndices) > 0 {
+ for _, idx := range orderedMatchIndices(m.matchIndices) {
+ if m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+ }
+
+ for idx := range m.frames {
+ if m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+}
+
+func keyString(msg tea.KeyPressMsg) string {
+ if s := msg.String(); s != "" {
+ return s
+ }
+ return msg.Text
+}
+
+func isSearchOpenKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "/" }
+func isNextMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "n" }
+func isPrevMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "N" }
+func isPauseKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "p" }
+func isResetBaselineKey(msg tea.KeyPressMsg) bool {
+ return keyString(msg) == "r"
+}
+func isCycleOrderKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "o" }
+func isHelpToggleKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "?" }
+
+func isZoomInKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomIn) || msg.Code == tea.KeyEnter || strings.EqualFold(keyString(msg), "enter")
+}
+
+func isZoomUndoKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomUndo) || msg.Code == tea.KeyBackspace
+}
+
+func isZoomResetKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ return key.Matches(msg, keys.ZoomReset) || msg.Code == tea.KeyEsc
+}
+
+func isMoveShallowerKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.MoveShallower) || msg.Code == tea.KeyDown || keyMatchesDirection(k, "down", 'B')
+}
+
+func isMoveDeeperKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.MoveDeeper) || msg.Code == tea.KeyUp || keyMatchesDirection(k, "up", 'A')
+}
+
+func isPrevSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.PrevSibling) || msg.Code == tea.KeyLeft || keyMatchesDirection(k, "left", 'D')
+}
+
+func isNextSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := keyString(msg)
+ return key.Matches(msg, keys.NextSibling) || msg.Code == tea.KeyRight || keyMatchesDirection(k, "right", 'C')
+}
+
+func keyMatchesDirection(keyName, plain string, ansiFinal byte) bool {
+ if keyName == plain || strings.HasSuffix(keyName, "+"+plain) {
+ return true
+ }
+ return isArrowEscapeSequence(keyName, ansiFinal)
+}
+
+func isArrowEscapeSequence(value string, ansiFinal byte) bool {
+ if len(value) < 3 || value[0] != '\x1b' {
+ return false
+ }
+ last := value[len(value)-1]
+ if last != ansiFinal {
+ return false
+ }
+ return value[1] == '[' || value[1] == 'O'
+}
+
func (m Model) visibleRowOffset() int {
if len(m.frames) == 0 {
return 0
@@ -535,7 +677,7 @@ func (m Model) visibleRowOffset() int {
if availableRows <= 0 {
return 0
}
- maxRow := maxFrameRow(m.frames)
+ maxRow := maxFrameRowForSet(m.frames, m.navigableFrameSet())
if maxRow+1 <= availableRows {
return 0
}
@@ -547,6 +689,10 @@ func (m *Model) ensureSelectionVisible() {
return
}
m.clampSelection()
+ m.ensureSelectionNavigable()
+ if !m.frameNavigable(m.selectedIdx) {
+ return
+ }
rowOffset := m.visibleRowOffset()
selected := m.frames[m.selectedIdx]
if selected.Row >= rowOffset {
@@ -556,6 +702,9 @@ func (m *Model) ensureSelectionVisible() {
bestIdx := -1
bestScore := int(^uint(0) >> 1)
for idx, frame := range m.frames {
+ if !m.frameNavigable(idx) {
+ continue
+ }
if frame.Row < rowOffset {
continue
}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index 0301b93..f58f890 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -142,6 +142,49 @@ func TestArrowDownFallsBackToVisibleDepthFromRoot(t *testing.T) {
}
}
+func TestArrowEscapeSequencesAreRecognized(t *testing.T) {
+ tests := []struct {
+ key string
+ dir string
+ ansiCode byte
+ }{
+ {key: "\x1b[A", dir: "up", ansiCode: 'A'},
+ {key: "\x1b[B", dir: "down", ansiCode: 'B'},
+ {key: "\x1b[C", dir: "right", ansiCode: 'C'},
+ {key: "\x1b[D", dir: "left", ansiCode: 'D'},
+ {key: "\x1bOA", dir: "up", ansiCode: 'A'}, // application mode
+ {key: "\x1bOB", dir: "down", ansiCode: 'B'}, // application mode
+ {key: "\x1b[1;2A", dir: "up", ansiCode: 'A'},
+ }
+ for _, tc := range tests {
+ if !keyMatchesDirection(tc.key, tc.dir, tc.ansiCode) {
+ t.Fatalf("expected key %q to match %s", tc.key, tc.dir)
+ }
+ }
+}
+
+func TestFilteredNavigationSkipsHiddenBranches(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Path: "root"},
+ {Name: "keep", Depth: 1, Col: 0, Row: 1, Path: "root" + pathSeparator + "keep"},
+ {Name: "drop", Depth: 1, Col: 40, Row: 1, Path: "root" + pathSeparator + "drop"},
+ }
+ m.searchQuery = "keep"
+ m.recomputeFilterState()
+ m.selectedIdx = 1
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected sibling navigation to stay on visible filtered branch, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
+ if m.selectedIdx != 0 {
+ t.Fatalf("expected down key to move to visible root ancestor, got idx %d", m.selectedIdx)
+ }
+}
+
func TestZoomInUndoResetAndNestedZoom(t *testing.T) {
m := newZoomModel()
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index 0c18d5c..517929e 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -191,7 +191,7 @@ func semanticFrameColor(name string) (color.Color, bool) {
}
// RenderTerminalView renders a terminal flamegraph viewport from laid out frames.
-func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet map[int]bool, isDark, searchActive bool, searchQuery string) string {
+func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, isDark, searchActive bool, searchQuery string) string {
if width < minFlameWidth {
return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)")
}
@@ -202,8 +202,27 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
return common.PanelStyle.Render("Flame: waiting for data...")
}
+ filterActive := strings.TrimSpace(searchQuery) != ""
+ if filterActive {
+ if filterSet == nil {
+ filterSet = computeFilterVisibleSetInto(frames, matchSet, nil)
+ }
+ if len(filterSet) == 0 {
+ return common.PanelStyle.Render(fmt.Sprintf("Flame: no frames match filter %q", searchQuery))
+ }
+ } else {
+ filterSet = nil
+ }
+
+ selectedIdx = normalizeSelectedIndex(frames, selectedIdx, filterSet)
+ selected := frames[selectedIdx]
+ viewPath := compactFramePath(frames[0].Path)
+ if subtreeSet == nil {
+ subtreeSet = computeSubtreeSet(frames, selectedIdx)
+ }
+
availableRows := height - 2 // toolbar + status
- maxRow := maxFrameRow(frames)
+ maxRow := maxFrameRowForSet(frames, filterSet)
rowOffset := 0
truncated := false
if maxRow+1 > availableRows {
@@ -211,22 +230,20 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
truncated = true
}
- if selectedIdx < 0 || selectedIdx >= len(frames) {
- selectedIdx = 0
- }
- selected := frames[selectedIdx]
- viewPath := compactFramePath(frames[0].Path)
- filterActive := strings.TrimSpace(searchQuery) != ""
- if subtreeSet == nil {
- subtreeSet = computeSubtreeSet(frames, selectedIdx)
+ visibleFrames := countVisibleFrames(frames, filterSet)
+ toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames)
+ if filterActive {
+ toolbar += fmt.Sprintf("/%d", len(frames))
}
-
- toolbar := fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", viewPath, len(frames), availableRows)
+ toolbar += fmt.Sprintf(" | rows:%d", availableRows)
if truncated {
toolbar += " | showing deepest levels"
}
toolbar = padOrTrim(toolbar, width)
- status := fmt.Sprintf("Selected: %s [%s] total=%d depth=%d", selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth)
+ selectedSystemShare := selected.Percent
+ if globalTotal > 0 {
+ selectedSystemShare = percentOfTotal(selected.Total, globalTotal)
+ }
if filterActive {
matches := orderedMatchIndices(matchSet)
pos := 0
@@ -235,18 +252,28 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
pos = idx + 1
}
}
- coverage := 0.0
+ frameCoverage := 0.0
if len(frames) > 0 {
- coverage = 100 * float64(len(matches)) / float64(len(frames))
+ frameCoverage = 100 * float64(visibleFrames) / float64(len(frames))
}
- status += fmt.Sprintf(" | Filter %q %d/%d (%.1f%%)", searchQuery, pos, len(matches), coverage)
+ filterSystemShare := filterSampleCoverage(frames, matchSet, globalTotal)
+ status := fmt.Sprintf("Filter %q: %.1f%% system (%d/%d matches, %d visible, %.1f%% frames) | Selected: %s total=%d depth=%d %.2f%% system",
+ searchQuery, filterSystemShare, pos, len(matches), visibleFrames, frameCoverage,
+ selected.Name, selected.Total, selected.Depth, selectedSystemShare)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive), width)
} else {
- status += fmt.Sprintf(" %.2f%%", selected.Percent)
+ status := fmt.Sprintf("Selected: %s [%s] total=%d depth=%d col=%d width=%d share=%.2f%%",
+ selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive), width)
}
- status = padOrTrim(status, width)
+}
- rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
+func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet, filterSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+ return buildRenderRows(frames, width, rowOffset, maxRow, selectedPath, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive)
+}
+func renderViewRows(toolbar, status string, rows []string, width int) string {
+ status = padOrTrim(status, width)
var b strings.Builder
b.Grow((width + 1) * (len(rows) + 2))
b.WriteString(toolbar)
@@ -264,9 +291,12 @@ type indexedFrame struct {
frame tuiFrame
}
-func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet, filterSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
rowsByDepth := make(map[int][]indexedFrame)
for idx, frame := range frames {
+ if filterSet != nil && !filterSet[idx] {
+ continue
+ }
if frame.Row < rowOffset || frame.Row > maxRow {
continue
}
@@ -359,6 +389,36 @@ func hasPathBoundaryPrefix(value, prefix string) bool {
return value[len(prefix)] == pathSeparatorByte
}
+func computeFilterVisibleSetInto(frames []tuiFrame, matchSet, visible map[int]bool) map[int]bool {
+ if visible == nil {
+ visible = make(map[int]bool)
+ } else {
+ for idx := range visible {
+ delete(visible, idx)
+ }
+ }
+ if len(matchSet) == 0 {
+ return visible
+ }
+
+ matchPaths := make([]string, 0, len(matchSet))
+ for idx := range matchSet {
+ if idx >= 0 && idx < len(frames) {
+ matchPaths = append(matchPaths, frames[idx].Path)
+ }
+ }
+ for idx, frame := range frames {
+ for _, matchPath := range matchPaths {
+ // Show matching frames and their full ancestry to root.
+ if frame.Path == matchPath || hasPathBoundaryPrefix(matchPath, frame.Path) {
+ visible[idx] = true
+ break
+ }
+ }
+ }
+ return visible
+}
+
func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style {
_ = searchActive
base := lipgloss.NewStyle().
@@ -393,7 +453,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
}
if filterActive {
- return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
+ if frameRelation(frame.Path, selectedPath) == relationAncestor {
+ return base.BorderLeft(true).BorderForeground(common.ColorAccent)
+ }
+ return base.Foreground(common.ColorPrimary)
}
if inSubtree {
@@ -458,8 +521,15 @@ func frameRelation(path, selectedPath string) relation {
}
func maxFrameRow(frames []tuiFrame) int {
+ return maxFrameRowForSet(frames, nil)
+}
+
+func maxFrameRowForSet(frames []tuiFrame, include map[int]bool) int {
maxRow := 0
- for _, frame := range frames {
+ for idx, frame := range frames {
+ if include != nil && !include[idx] {
+ continue
+ }
if frame.Row > maxRow {
maxRow = frame.Row
}
@@ -467,6 +537,96 @@ func maxFrameRow(frames []tuiFrame) int {
return maxRow
}
+func countVisibleFrames(frames []tuiFrame, include map[int]bool) int {
+ if include == nil {
+ return len(frames)
+ }
+ count := 0
+ for idx := range frames {
+ if include[idx] {
+ count++
+ }
+ }
+ return count
+}
+
+func normalizeSelectedIndex(frames []tuiFrame, selectedIdx int, include map[int]bool) int {
+ if len(frames) == 0 {
+ return 0
+ }
+ if selectedIdx >= 0 && selectedIdx < len(frames) && (include == nil || include[selectedIdx]) {
+ return selectedIdx
+ }
+ if include != nil {
+ for idx := range frames {
+ if include[idx] {
+ return idx
+ }
+ }
+ }
+ return 0
+}
+
+func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) float64 {
+ if len(frames) == 0 || len(matchSet) == 0 {
+ return 0
+ }
+ rootTotal := totalBase
+ if rootTotal == 0 {
+ rootTotal = frames[0].Total
+ }
+ if rootTotal == 0 {
+ return 0
+ }
+ type matchRoot struct {
+ path string
+ total uint64
+ }
+ roots := make([]matchRoot, 0, len(matchSet))
+ for idx := range matchSet {
+ if idx < 0 || idx >= len(frames) {
+ continue
+ }
+ roots = append(roots, matchRoot{
+ path: frames[idx].Path,
+ total: frames[idx].Total,
+ })
+ }
+ sort.Slice(roots, func(i, j int) bool {
+ return len(roots[i].path) < len(roots[j].path)
+ })
+ merged := make([]matchRoot, 0, len(roots))
+ for _, candidate := range roots {
+ covered := false
+ for _, root := range merged {
+ if candidate.path == root.path || hasPathBoundaryPrefix(candidate.path, root.path) {
+ covered = true
+ break
+ }
+ }
+ if covered {
+ continue
+ }
+ merged = append(merged, candidate)
+ }
+ var coveredTotal uint64
+ for _, root := range merged {
+ coveredTotal += root.total
+ }
+ coverage := 100 * float64(coveredTotal) / float64(rootTotal)
+ if coverage > 100 {
+ return 100
+ }
+ return coverage
+}
+
+func percentOfTotal(value, total uint64) float64 {
+ if total == 0 {
+ return 0
+ }
+ return 100 * float64(value) / float64(total)
+}
+
func padOrTrim(s string, width int) string {
if width <= 0 {
return ""
diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go
index efd8063..0f1587b 100644
--- a/internal/tui/flamegraph/renderer_test.go
+++ b/internal/tui/flamegraph/renderer_test.go
@@ -152,7 +152,7 @@ func TestTerminalFrameColorSemanticPalette(t *testing.T) {
}
func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) {
- out := RenderTerminalView(nil, 50, 10, 0, nil, nil, true, false, "")
+ out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, true, false, "")
if !strings.Contains(out, "terminal too narrow") {
t.Fatalf("expected narrow terminal warning, got %q", out)
}
@@ -168,7 +168,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) {
}
frames := BuildTerminalLayout(snapshot, 80, 6)
- out := RenderTerminalView(frames, 80, 6, 1, nil, nil, true, false, "")
+ out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, true, false, "")
if !strings.Contains(out, "Flame | view:root | frames:2") {
t.Fatalf("expected toolbar to include frame count, got %q", out)
}
@@ -197,12 +197,68 @@ func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) {
frames := BuildTerminalLayout(snapshot, 80, 6)
matchSet := map[int]bool{1: true}
- out := RenderTerminalView(frames, 80, 6, 1, nil, matchSet, true, false, "child")
+ out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, true, false, "child")
if !strings.Contains(out, `Filter "child"`) {
t.Fatalf("expected filter context in status line, got %q", out)
}
}
+func TestRenderTerminalViewFilterOnlyShowsMatchingBranchToRoot(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "keep",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "needle", Total: 60},
+ },
+ },
+ {
+ Name: "drop",
+ Total: 40,
+ Children: []*snapshotNode{
+ {Name: "noise", Total: 40},
+ },
+ },
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 8)
+ needleIdx := frameIndexByPathRenderer(frames, "root"+pathSeparator+"keep"+pathSeparator+"needle")
+ if needleIdx < 0 {
+ t.Fatalf("expected needle frame in layout")
+ }
+ matchSet := map[int]bool{needleIdx: true}
+
+ out := RenderTerminalView(frames, 80, 8, needleIdx, nil, matchSet, nil, 100, true, false, "needle")
+ if !strings.Contains(out, `Filter "needle": 60.0% system`) {
+ t.Fatalf("expected filter status to report 60.0%% system share, got %q", out)
+ }
+ if !strings.Contains(out, "keep") || !strings.Contains(out, "needle") {
+ t.Fatalf("expected matching branch to remain visible, got %q", out)
+ }
+ if strings.Contains(out, "drop") || strings.Contains(out, "noise") {
+ t.Fatalf("expected non-matching branch to be hidden, got %q", out)
+ }
+}
+
+func TestFilterSampleCoverageAvoidsDoubleCountingNestedMatches(t *testing.T) {
+ frames := []tuiFrame{
+ {Path: "root", Total: 100},
+ {Path: "root" + pathSeparator + "A", Total: 60},
+ {Path: "root" + pathSeparator + "A" + pathSeparator + "A1", Total: 30},
+ {Path: "root" + pathSeparator + "B", Total: 40},
+ }
+ matchSet := map[int]bool{
+ 1: true, // A
+ 2: true, // A1 (nested under A)
+ }
+ if got := filterSampleCoverage(frames, matchSet, 100); got != 60 {
+ t.Fatalf("expected nested matches to count once at 60%%, got %.1f%%", got)
+ }
+}
+
func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) {
snapshot := &snapshotNode{
Name: "root",
@@ -230,7 +286,7 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) {
},
}
frames := BuildTerminalLayout(snapshot, 80, 10)
- out := RenderTerminalView(frames, 80, 4, 0, nil, nil, true, false, "")
+ out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, true, false, "")
if !strings.Contains(out, "showing deepest levels") {
t.Fatalf("expected truncation hint in toolbar, got %q", out)
}
@@ -272,3 +328,12 @@ func hasFrame(frames []tuiFrame, path string) bool {
}
return false
}
+
+func frameIndexByPathRenderer(frames []tuiFrame, path string) int {
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ return -1
+}
diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go
index 9ec8ccd..6bedc3e 100644
--- a/internal/tui/flamegraph/search.go
+++ b/internal/tui/flamegraph/search.go
@@ -17,29 +17,22 @@ func (m *Model) clearSearch() {
m.searchActive = false
m.searchQuery = ""
clearBoolMap(m.matchIndices)
+ clearBoolMap(m.filterVisible)
m.searchInput.SetValue("")
m.searchInput.Blur()
m.statusMessage = "Filter cleared"
}
func (m *Model) applySearchQuery(raw string) {
- query := strings.ToLower(strings.TrimSpace(raw))
- m.searchQuery = query
- if m.matchIndices == nil {
- m.matchIndices = make(map[int]bool)
- } else {
- clearBoolMap(m.matchIndices)
- }
+ m.searchQuery = strings.ToLower(strings.TrimSpace(raw))
+ m.recomputeFilterState()
+ query := m.searchQuery
if query == "" {
+ m.ensureSelectionNavigable()
m.statusMessage = "Filter cleared"
return
}
- for idx, frame := range m.frames {
- if strings.Contains(strings.ToLower(frame.Name), query) {
- m.matchIndices[idx] = true
- }
- }
if len(m.matchIndices) > 0 {
m.jumpMatch(1)
m.statusMessage = fmt.Sprintf("Filter %q: %d matches", query, len(m.matchIndices))
@@ -75,6 +68,29 @@ func (m *Model) jumpMatch(direction int) {
m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
}
+func (m *Model) recomputeFilterState() {
+ if m.matchIndices == nil {
+ m.matchIndices = make(map[int]bool)
+ } else {
+ clearBoolMap(m.matchIndices)
+ }
+ if m.filterVisible == nil {
+ m.filterVisible = make(map[int]bool)
+ } else {
+ clearBoolMap(m.filterVisible)
+ }
+ if m.searchQuery == "" {
+ return
+ }
+
+ for idx, frame := range m.frames {
+ if strings.Contains(strings.ToLower(frame.Name), m.searchQuery) {
+ m.matchIndices[idx] = true
+ }
+ }
+ m.filterVisible = computeFilterVisibleSetInto(m.frames, m.matchIndices, m.filterVisible)
+}
+
func orderedMatchIndices(matchSet map[int]bool) []int {
matches := make([]int, 0, len(matchSet))
for idx := range matchSet {