diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 09:05:51 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 09:05:51 +0200 |
| commit | 10c5d48413afaef88626419d8c4bf9fbf6f1c902 (patch) | |
| tree | fe6c86eaf16c16070aa8025e207e5d88bd5595c6 | |
| parent | 0a69582e7f8111c2a508d8f062de91a06f296974 (diff) | |
Fix flamegraph navigation, filtering, and system-share feedback
| -rw-r--r-- | internal/tui/dashboard/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 5 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 221 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 43 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 204 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 73 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 40 |
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 { |
