diff options
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 15 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 105 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 70 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 87 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 11 |
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) } } |
