summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/controls.go15
-rw-r--r--internal/tui/flamegraph/model.go105
-rw-r--r--internal/tui/flamegraph/model_test.go70
-rw-r--r--internal/tui/flamegraph/renderer.go87
-rw-r--r--internal/tui/flamegraph/renderer_test.go11
5 files changed, 245 insertions, 43 deletions
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index b307717..cd74df5 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -92,7 +92,7 @@ func (m Model) helpOverlay() string {
if width <= 0 {
width = 80
}
- help := "Flame help: j/k depth h/l sibling enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order ? help"
+ help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order ? help"
return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
}
@@ -118,8 +118,17 @@ func (m Model) selectionStatusLine() string {
if m.globalTotal > 0 {
systemShare = percentOfTotal(frame.Total, m.globalTotal)
}
- line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %.2f%% system",
- mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, systemShare)
+ shareLabel := fmt.Sprintf("%.2f%% system", systemShare)
+ if strings.TrimSpace(m.searchQuery) != "" && len(m.matchIndices) > 0 {
+ filterTotal, _ := filterCoverageTotals(m.frames, m.matchIndices, m.globalTotal)
+ if filterTotal > 0 {
+ selectedFilterTotal := filterCoverageTotalForPath(m.frames, m.matchIndices, frame.Path)
+ filterShare := percentOfTotal(selectedFilterTotal, filterTotal)
+ shareLabel = fmt.Sprintf("%.2f%% filter", filterShare)
+ }
+ }
+ line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %s",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, shareLabel)
if m.searchQuery != "" {
line += " | filter:" + m.searchQuery
}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 07bae5d..2f40a30 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -38,6 +38,8 @@ type flameKeyMap struct {
MoveDeeper key.Binding
PrevSibling key.Binding
NextSibling key.Binding
+ JumpTop key.Binding
+ JumpRoot key.Binding
ZoomIn key.Binding
ZoomUndo key.Binding
ZoomReset key.Binding
@@ -49,6 +51,8 @@ func defaultFlameKeyMap() flameKeyMap {
MoveDeeper: key.NewBinding(key.WithKeys("k", "up")),
PrevSibling: key.NewBinding(key.WithKeys("h", "left")),
NextSibling: key.NewBinding(key.WithKeys("l", "right")),
+ JumpTop: key.NewBinding(key.WithKeys("pgup", "pageup")),
+ JumpRoot: key.NewBinding(key.WithKeys("pgdown", "pgdn", "pagedown")),
ZoomIn: key.NewBinding(key.WithKeys("enter")),
ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u", "esc")),
ZoomReset: key.NewBinding(),
@@ -233,6 +237,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case isNextSiblingKey(msg, m.keys):
handled = true
m.moveSibling(1)
+ case isJumpTopKey(msg, m.keys):
+ handled = true
+ m.jumpToTop()
+ case isJumpRootKey(msg, m.keys):
+ handled = true
+ m.jumpToRoot()
}
if m.selectedIdx != prev {
m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
@@ -263,7 +273,9 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
isMoveShallowerKey(msg, m.keys),
isMoveDeeperKey(msg, m.keys),
isPrevSiblingKey(msg, m.keys),
- isNextSiblingKey(msg, m.keys):
+ isNextSiblingKey(msg, m.keys),
+ isJumpTopKey(msg, m.keys),
+ isJumpRootKey(msg, m.keys):
return true
default:
return false
@@ -582,6 +594,87 @@ func (m *Model) moveSibling(delta int) {
}
}
+func (m *Model) jumpToTop() {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+
+ include := m.navigableFrameSet()
+ currentCol := m.frames[m.selectedIdx].Col
+ bestIdx := -1
+ bestDepth := -1
+ bestDist := int(^uint(0) >> 1)
+
+ for idx, frame := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ dist := abs(frame.Col - currentCol)
+ if frame.Depth > bestDepth {
+ bestDepth = frame.Depth
+ bestIdx = idx
+ bestDist = dist
+ continue
+ }
+ if frame.Depth == bestDepth {
+ if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) {
+ bestIdx = idx
+ bestDist = dist
+ }
+ }
+ }
+ if bestIdx >= 0 {
+ m.selectedIdx = bestIdx
+ }
+}
+
+func (m *Model) jumpToRoot() {
+ if len(m.frames) == 0 {
+ return
+ }
+ m.clampSelection()
+ m.ensureSelectionNavigable()
+
+ rootPath := m.currentRootPath()
+ if rootPath != "" {
+ if idx := m.frameIndexByPath(rootPath); idx >= 0 {
+ if !m.filterActive() || m.frameNavigable(idx) {
+ m.selectedIdx = idx
+ return
+ }
+ }
+ }
+
+ include := m.navigableFrameSet()
+ currentCol := m.frames[m.selectedIdx].Col
+ bestIdx := -1
+ bestDepth := int(^uint(0) >> 1)
+ bestDist := int(^uint(0) >> 1)
+ for idx, frame := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ dist := abs(frame.Col - currentCol)
+ if frame.Depth < bestDepth {
+ bestDepth = frame.Depth
+ bestDist = dist
+ bestIdx = idx
+ continue
+ }
+ if frame.Depth == bestDepth {
+ if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) {
+ bestDist = dist
+ bestIdx = idx
+ }
+ }
+ }
+ if bestIdx >= 0 {
+ m.selectedIdx = bestIdx
+ }
+}
+
func framesAtDepth(frames []tuiFrame, depth int) []int {
return framesAtDepthFiltered(frames, depth, nil)
}
@@ -813,6 +906,16 @@ func isNextSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
return key.Matches(msg, keys.NextSibling) || msg.Code == tea.KeyRight || keyMatchesDirection(k, "right", 'C')
}
+func isJumpTopKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := strings.ToLower(keyString(msg))
+ return key.Matches(msg, keys.JumpTop) || msg.Code == tea.KeyPgUp || k == "pgup" || k == "pageup"
+}
+
+func isJumpRootKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
+ k := strings.ToLower(keyString(msg))
+ return key.Matches(msg, keys.JumpRoot) || msg.Code == tea.KeyPgDown || k == "pgdown" || k == "pgdn" || k == "pagedown"
+}
+
func keyMatchesDirection(keyName, plain string, ansiFinal byte) bool {
if keyName == plain || strings.HasSuffix(keyName, "+"+plain) {
return true
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index e253c76..093bf34 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -193,6 +193,37 @@ func TestHorizontalTraversalFallbackFromRoot(t *testing.T) {
}
}
+func TestPageUpJumpsSelectionToTopMostDepth(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"},
+ {Name: "B", Depth: 1, Col: 40, Path: "root" + pathSeparator + "B"},
+ {Name: "A1", Depth: 2, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1"},
+ {Name: "B1", Depth: 2, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1"},
+ {Name: "A2", Depth: 3, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1" + pathSeparator + "A2"},
+ {Name: "B2", Depth: 3, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1" + pathSeparator + "B2"},
+ }
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"B"+pathSeparator+"B1")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp})
+ if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"B"+pathSeparator+"B1"+pathSeparator+"B2"; got != want {
+ t.Fatalf("expected pgup to jump to deepest top frame %q, got %q", want, got)
+ }
+}
+
+func TestPageDownJumpsSelectionToCurrentViewRoot(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgDown})
+ if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected pgdn to jump to current zoom root %q, got %q", want, got)
+ }
+}
+
func TestPausedStateStillAllowsNavigation(t *testing.T) {
m := NewModel(nil)
m.frames = []tuiFrame{
@@ -635,6 +666,45 @@ func TestViewIncludesSelectionStatusBar(t *testing.T) {
}
}
+func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(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},
+ },
+ },
+ },
+ }
+ m := NewModel(nil)
+ m.width = 220
+ m.height = 12
+ m.frames = BuildTerminalLayout(snapshot, m.width, m.height)
+ m.globalTotal = 100
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"keep"+pathSeparator+"needle")
+ m.searchQuery = "needle"
+ m.recomputeFilterState()
+
+ view := m.View().Content
+ if !strings.Contains(view, "100.00% filter") {
+ t.Fatalf("expected filtered selection share in status line, got %q", view)
+ }
+ if !strings.Contains(view, "drop") || !strings.Contains(view, "noise") {
+ t.Fatalf("expected non-matching branches to remain visible while filtering, got %q", view)
+ }
+}
+
func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
m := NewModel(liveTrie)
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index 67ad66e..f2ab08e 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -222,7 +222,7 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
}
availableRows := height - 2 // toolbar + status
- maxRow := maxFrameRowForSet(frames, filterSet)
+ maxRow := maxFrameRowForSet(frames, nil)
rowOffset := 0
truncated := false
if maxRow+1 > availableRows {
@@ -230,11 +230,8 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
truncated = true
}
- visibleFrames := countVisibleFrames(frames, filterSet)
+ visibleFrames := countVisibleFrames(frames, nil)
toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames)
- if filterActive {
- toolbar += fmt.Sprintf("/%d", len(frames))
- }
toolbar += fmt.Sprintf(" | rows:%d", availableRows)
if truncated {
toolbar += " | showing deepest levels"
@@ -245,6 +242,13 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
selectedSystemShare = percentOfTotal(selected.Total, globalTotal)
}
if filterActive {
+ filterCoveredTotal, filterBaseTotal := filterCoverageTotals(frames, matchSet, globalTotal)
+ filterSystemShare := percentOfTotal(filterCoveredTotal, filterBaseTotal)
+ selectedFilterShare := 0.0
+ if filterCoveredTotal > 0 {
+ selectedMatchTotal := filterCoverageTotalForPath(frames, matchSet, selected.Path)
+ selectedFilterShare = percentOfTotal(selectedMatchTotal, filterCoveredTotal)
+ }
matches := orderedMatchIndices(matchSet)
pos := 0
if len(matches) > 0 {
@@ -256,20 +260,19 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
if len(frames) > 0 {
frameCoverage = 100 * float64(visibleFrames) / float64(len(frames))
}
- 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)
+ status := fmt.Sprintf("Filter %q: %.1f%% system (%d/%d matches, %.1f%% frames shown) | Selected: %s total=%d depth=%d %.2f%% filter",
+ searchQuery, filterSystemShare, pos, len(matches), frameCoverage,
+ selected.Name, selected.Total, selected.Depth, selectedFilterShare)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width)
} else {
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)
+ return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width)
}
}
-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 rowsForRender(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+ return buildRenderRows(frames, width, rowOffset, maxRow, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
}
func renderViewRows(toolbar, status string, rows []string, width int) string {
@@ -291,12 +294,9 @@ type indexedFrame struct {
frame tuiFrame
}
-func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet, filterSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
+func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet 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
}
@@ -453,10 +453,7 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
}
if filterActive {
- if frameRelation(frame.Path, selectedPath) == relationAncestor {
- return base.BorderLeft(true).BorderForeground(common.ColorAccent)
- }
- return base.Foreground(common.ColorPrimary)
+ return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
}
if inSubtree {
@@ -568,20 +565,48 @@ func normalizeSelectedIndex(frames []tuiFrame, selectedIdx int, include map[int]
}
func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) float64 {
+ coveredTotal, rootTotal := filterCoverageTotals(frames, matchSet, totalBase)
+ return percentOfTotal(coveredTotal, rootTotal)
+}
+
+func filterCoverageTotals(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) (coveredTotal uint64, rootTotal uint64) {
if len(frames) == 0 || len(matchSet) == 0 {
- return 0
+ return 0, 0
}
- rootTotal := totalBase
+ rootTotal = totalBase
if rootTotal == 0 {
rootTotal = frames[0].Total
}
if rootTotal == 0 {
+ return 0, 0
+ }
+ roots := compactMatchRoots(frames, matchSet)
+ for _, root := range roots {
+ coveredTotal += root.total
+ }
+ return coveredTotal, rootTotal
+}
+
+func filterCoverageTotalForPath(frames []tuiFrame, matchSet map[int]bool, path string) uint64 {
+ if path == "" || len(frames) == 0 || len(matchSet) == 0 {
return 0
}
- type matchRoot struct {
- path string
- total uint64
+ roots := compactMatchRoots(frames, matchSet)
+ var coveredTotal uint64
+ for _, root := range roots {
+ if root.path == path || hasPathBoundaryPrefix(root.path, path) {
+ coveredTotal += root.total
+ }
}
+ return coveredTotal
+}
+
+type matchRoot struct {
+ path string
+ total uint64
+}
+
+func compactMatchRoots(frames []tuiFrame, matchSet map[int]bool) []matchRoot {
roots := make([]matchRoot, 0, len(matchSet))
for idx := range matchSet {
if idx < 0 || idx >= len(frames) {
@@ -609,15 +634,7 @@ func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase ui
}
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
+ return merged
}
func percentOfTotal(value, total uint64) float64 {
diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go
index 0f1587b..b85bceb 100644
--- a/internal/tui/flamegraph/renderer_test.go
+++ b/internal/tui/flamegraph/renderer_test.go
@@ -203,7 +203,7 @@ func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) {
}
}
-func TestRenderTerminalViewFilterOnlyShowsMatchingBranchToRoot(t *testing.T) {
+func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) {
snapshot := &snapshotNode{
Name: "root",
Total: 100,
@@ -231,15 +231,18 @@ func TestRenderTerminalViewFilterOnlyShowsMatchingBranchToRoot(t *testing.T) {
}
matchSet := map[int]bool{needleIdx: true}
- out := RenderTerminalView(frames, 80, 8, needleIdx, nil, matchSet, nil, 100, true, false, "needle")
+ out := RenderTerminalView(frames, 180, 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)
+ if !strings.Contains(out, "drop") || !strings.Contains(out, "noise") {
+ t.Fatalf("expected non-matching branch to remain visible (greyed), got %q", out)
+ }
+ if !strings.Contains(out, "100.00% filter") {
+ t.Fatalf("expected selected match share to be computed against filtered total, got %q", out)
}
}